From 0264cebb39de89d731ea0fb6e63836168e14143a Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Sun, 1 Mar 2026 22:43:54 -0500 Subject: [PATCH 01/37] refactor(ssm): restructure into modular command architecture - Reorganize SSM module into feature-based structure with commands/, core/, and utils/ directories - Split monolithic ssm.py (600+ lines) into granular command files under commands/database/, commands/hosts/, commands/instance/ - Create database subcommands: add, connect, list, remove for database connection management - Create hosts subcommands: add, clear, list, remove, setup for /etc/hosts management - Create instance subcommands: add, list, remove, shell for EC2 instance operations - Add port forwarding and shortcuts as standalone commands - Move business logic to core/ module with config, port_forwarder, and session managers - Add utils/ module with hosts_manager for shared utilities - Update code organization documentation to enforce universal feature module structure for all commands - Clarify .gitignore instance/ path to avoid conflicts with new instance/ command directory --- .gitignore | 2 +- .kiro/steering/code-organization.md | 218 ++++--- cli_tool/commands/ssm.py | 646 +-------------------- cli_tool/ssm/__init__.py | 9 +- cli_tool/ssm/commands/__init__.py | 15 + cli_tool/ssm/commands/database/__init__.py | 23 + cli_tool/ssm/commands/database/add.py | 26 + cli_tool/ssm/commands/database/connect.py | 229 ++++++++ cli_tool/ssm/commands/database/list.py | 32 + cli_tool/ssm/commands/database/remove.py | 20 + cli_tool/ssm/commands/forward.py | 44 ++ cli_tool/ssm/commands/hosts/__init__.py | 25 + cli_tool/ssm/commands/hosts/add.py | 40 ++ cli_tool/ssm/commands/hosts/clear.py | 21 + cli_tool/ssm/commands/hosts/list.py | 30 + cli_tool/ssm/commands/hosts/remove.py | 29 + cli_tool/ssm/commands/hosts/setup.py | 81 +++ cli_tool/ssm/commands/instance/__init__.py | 23 + cli_tool/ssm/commands/instance/add.py | 23 + cli_tool/ssm/commands/instance/list.py | 32 + cli_tool/ssm/commands/instance/remove.py | 20 + cli_tool/ssm/commands/instance/shell.py | 31 + cli_tool/ssm/commands/shortcuts.py | 42 ++ cli_tool/ssm/core/__init__.py | 7 + cli_tool/ssm/{ => core}/config.py | 0 cli_tool/ssm/{ => core}/port_forwarder.py | 0 cli_tool/ssm/{ => core}/session.py | 0 cli_tool/ssm/utils/__init__.py | 5 + cli_tool/ssm/{ => utils}/hosts_manager.py | 0 29 files changed, 955 insertions(+), 718 deletions(-) create mode 100644 cli_tool/ssm/commands/__init__.py create mode 100644 cli_tool/ssm/commands/database/__init__.py create mode 100644 cli_tool/ssm/commands/database/add.py create mode 100644 cli_tool/ssm/commands/database/connect.py create mode 100644 cli_tool/ssm/commands/database/list.py create mode 100644 cli_tool/ssm/commands/database/remove.py create mode 100644 cli_tool/ssm/commands/forward.py create mode 100644 cli_tool/ssm/commands/hosts/__init__.py create mode 100644 cli_tool/ssm/commands/hosts/add.py create mode 100644 cli_tool/ssm/commands/hosts/clear.py create mode 100644 cli_tool/ssm/commands/hosts/list.py create mode 100644 cli_tool/ssm/commands/hosts/remove.py create mode 100644 cli_tool/ssm/commands/hosts/setup.py create mode 100644 cli_tool/ssm/commands/instance/__init__.py create mode 100644 cli_tool/ssm/commands/instance/add.py create mode 100644 cli_tool/ssm/commands/instance/list.py create mode 100644 cli_tool/ssm/commands/instance/remove.py create mode 100644 cli_tool/ssm/commands/instance/shell.py create mode 100644 cli_tool/ssm/commands/shortcuts.py create mode 100644 cli_tool/ssm/core/__init__.py rename cli_tool/ssm/{ => core}/config.py (100%) rename cli_tool/ssm/{ => core}/port_forwarder.py (100%) rename cli_tool/ssm/{ => core}/session.py (100%) create mode 100644 cli_tool/ssm/utils/__init__.py rename cli_tool/ssm/{ => utils}/hosts_manager.py (100%) diff --git a/.gitignore b/.gitignore index 971e2e2..043a6c3 100755 --- a/.gitignore +++ b/.gitignore @@ -68,7 +68,7 @@ db.sqlite3 db.sqlite3-journal # Flask stuff: -instance/ +./instance/ .webassets-cache # Scrapy stuff: diff --git a/.kiro/steering/code-organization.md b/.kiro/steering/code-organization.md index ff0523a..23358f0 100644 --- a/.kiro/steering/code-organization.md +++ b/.kiro/steering/code-organization.md @@ -6,42 +6,49 @@ All commands in Devo CLI must follow this standardized structure for consistency ## Directory Structure -### Simple Commands (Single File) +### Universal Feature Module Structure -For commands with minimal logic (< 200 lines): - -``` -cli_tool/commands/ -└── command_name.py # All logic in one file -``` - -**Examples:** `upgrade.py`, `completion.py`, `codeartifact_login.py` - -### Complex Commands (Feature Module) - -For commands with significant logic (> 200 lines) or multiple subcommands: +**ALL commands MUST follow this structure, regardless of size or complexity:** ``` cli_tool/feature_name/ -├── __init__.py # Exports main classes/functions +├── __init__.py # Public API exports ├── README.md # Feature documentation (optional) -├── commands/ # Click command definitions -│ ├── __init__.py -│ ├── subcommand1.py -│ └── subcommand2.py -├── core/ # Business logic +├── commands/ # CLI command definitions +│ ├── __init__.py # Registers all command groups +│ ├── resource1/ # Command group for resource1 +│ │ ├── __init__.py # Registers resource1 commands +│ │ ├── list.py # List resources (~30-50 lines) +│ │ ├── add.py # Create resource (~30-50 lines) +│ │ ├── remove.py # Delete resource (~20-30 lines) +│ │ └── update.py # Update resource (~30-50 lines) +│ ├── resource2/ # Command group for resource2 +│ │ ├── __init__.py +│ │ ├── command1.py +│ │ └── command2.py +│ ├── standalone.py # Standalone command (no group) +│ └── shortcuts.py # Shortcuts for common commands (optional) +├── core/ # Business logic (no Click dependencies) │ ├── __init__.py -│ ├── service.py -│ └── processor.py -└── utils/ # Feature-specific utilities +│ ├── manager.py # Main service class +│ └── processor.py # Data processing +└── utils/ # Feature-specific utilities (optional) ├── __init__.py - └── helpers.py + └── helpers.py # Helper functions cli_tool/commands/ └── feature_name.py # Thin wrapper that imports from cli_tool/feature_name/ ``` -**Examples:** `dynamodb/`, `code_reviewer/` +**Key Principles:** +- One file per command (~50-100 lines each) +- Commands grouped in subdirectories by domain +- Shortcuts/aliases in separate file +- All feature code contained within feature directory + +**Examples:** `ssm/`, `dynamodb/`, `code_reviewer/` + +**No exceptions:** Even single-command features use this structure for consistency. ## File Naming Conventions @@ -50,30 +57,9 @@ cli_tool/commands/ - **Classes:** `PascalCase` (e.g., `SSMConfigManager`, `BaseAgent`) - **Functions:** `snake_case` (e.g., `load_config()`, `get_template()`) -## Standard Module Organization +## Code Examples -### Feature Module Structure - -``` -cli_tool/feature_name/ -├── __init__.py # Public API exports -├── README.md # Feature overview and usage -├── commands/ # CLI command definitions -│ ├── __init__.py -│ ├── list.py # List resources -│ ├── create.py # Create resources -│ ├── delete.py # Delete resources -│ └── update.py # Update resources -├── core/ # Business logic (no Click dependencies) -│ ├── __init__.py -│ ├── manager.py # Main service class -│ └── processor.py # Data processing -└── utils/ # Feature-specific utilities - ├── __init__.py - └── helpers.py # Helper functions -``` - -### Command File Structure +### Command File Structure (Individual Command) ```python """Command description.""" @@ -86,20 +72,40 @@ from cli_tool.feature_name.core import FeatureManager console = Console() -@click.group() -def feature_name(): - """Feature description.""" - pass - - -@feature_name.command("subcommand") +@click.command() @click.argument("name") @click.option("--flag", is_flag=True, help="Flag description") -def subcommand(name, flag): - """Subcommand description.""" - manager = FeatureManager() - result = manager.do_something(name, flag) - console.print(f"[green]✓ Success: {result}[/green]") +def command_name(name, flag): + """Command description.""" + manager = FeatureManager() + result = manager.do_something(name, flag) + console.print(f"[green]✓ Success: {result}[/green]") +``` + +### Command Group Registration (__init__.py) + +```python +"""Resource commands.""" + +import click + +from cli_tool.feature_name.commands.resource.add import add_resource +from cli_tool.feature_name.commands.resource.list import list_resources +from cli_tool.feature_name.commands.resource.remove import remove_resource + + +def register_resource_commands(parent_group): + """Register resource-related commands.""" + + @parent_group.group("resource") + def resource(): + """Manage resources""" + pass + + # Register all resource commands + resource.add_command(list_resources, "list") + resource.add_command(add_resource, "add") + resource.add_command(remove_resource, "remove") ``` ## Configuration Management @@ -162,60 +168,107 @@ def save_feature_config(feature_config: Dict): ## Current State vs Standard ### ✅ Follows Standard +- `cli_tool/ssm/` - Reference implementation with commands/, core/, utils/ - `cli_tool/dynamodb/` - Well organized with commands/, core/, utils/ - `cli_tool/code_reviewer/` - Good separation with prompt/, tools/ -- `cli_tool/commands/upgrade.py` - Simple, single file ### ⚠️ Needs Refactoring -- `cli_tool/aws_login/` - Should be `cli_tool/aws_login/commands/` structure -- `cli_tool/ssm/` - Missing commands/ subdirectory -- `cli_tool/commands/ssm.py` - Too large (600+ lines), should split into subcommands +- `cli_tool/aws_login/` - Missing commands/ subdirectory +- `cli_tool/commands/upgrade.py` - Should be `cli_tool/upgrade/` with commands/, core/ +- `cli_tool/commands/completion.py` - Should be `cli_tool/completion/` with commands/, core/ +- `cli_tool/commands/codeartifact_login.py` - Should be `cli_tool/codeartifact/` with commands/, core/ +- `cli_tool/commands/commit_prompt.py` - Should be `cli_tool/commit/` with commands/, core/ +- `cli_tool/commands/eventbridge.py` - Should be `cli_tool/eventbridge/` with commands/, core/ +- `cli_tool/commands/config.py` - Should be `cli_tool/config_cmd/` with commands/, core/ ## Migration Plan -### Phase 1: Standardize Existing Features -1. Move `cli_tool/aws_login/*.py` → `cli_tool/aws_login/commands/` -2. Split `cli_tool/commands/ssm.py` → `cli_tool/ssm/commands/` -3. Create `cli_tool/ssm/core/` for business logic +### Phase 1: Standardize Existing Features (Priority Order) +1. ✅ **SSM** - COMPLETED +2. **AWS Login** - Move files → `cli_tool/aws_login/commands/` and `cli_tool/aws_login/core/` +3. **Upgrade** - Convert `cli_tool/commands/upgrade.py` → `cli_tool/upgrade/` +4. **Completion** - Convert `cli_tool/commands/completion.py` → `cli_tool/completion/` +5. **CodeArtifact** - Convert `cli_tool/commands/codeartifact_login.py` → `cli_tool/codeartifact/` +6. **Commit** - Convert `cli_tool/commands/commit_prompt.py` → `cli_tool/commit/` +7. **EventBridge** - Convert `cli_tool/commands/eventbridge.py` → `cli_tool/eventbridge/` +8. **Config** - Convert `cli_tool/commands/config.py` → `cli_tool/config_cmd/` ### Phase 2: New Features All new features must follow the standard structure from day one. +### Phase 3: Remove cli_tool/commands/ +Once all features are migrated, `cli_tool/commands/` should only contain thin wrappers that import from feature modules. + ## Examples -### Good: DynamoDB Structure +### ✅ Good: SSM Structure (Reference Implementation) +``` +cli_tool/ssm/ +├── __init__.py +├── commands/ +│ ├── __init__.py +│ ├── database/ # Database command group +│ │ ├── __init__.py +│ │ ├── connect.py # ~230 lines (complex logic) +│ │ ├── list.py # ~30 lines +│ │ ├── add.py # ~25 lines +│ │ └── remove.py # ~20 lines +│ ├── instance/ # Instance command group +│ │ ├── __init__.py +│ │ ├── shell.py # ~30 lines +│ │ ├── list.py # ~30 lines +│ │ ├── add.py # ~25 lines +│ │ └── remove.py # ~20 lines +│ ├── hosts/ # Hosts command group +│ │ ├── __init__.py +│ │ ├── setup.py # ~80 lines +│ │ ├── list.py # ~25 lines +│ │ ├── clear.py # ~20 lines +│ │ ├── add.py # ~35 lines +│ │ └── remove.py # ~25 lines +│ ├── forward.py # Standalone command (~40 lines) +│ └── shortcuts.py # Shortcuts (~40 lines) +├── core/ +│ ├── __init__.py +│ ├── config.py # SSMConfigManager +│ ├── session.py # SSMSession +│ └── port_forwarder.py # PortForwarder +└── utils/ + ├── __init__.py + └── hosts_manager.py # HostsManager +``` + +### ✅ Good: DynamoDB Structure ``` cli_tool/dynamodb/ ├── __init__.py ├── commands/ +│ ├── __init__.py │ ├── export_table.py # Main export command │ └── list_templates.py # Template management ├── core/ +│ ├── __init__.py │ ├── exporter.py # Export logic │ └── parallel_scanner.py # Scanning logic └── utils/ + ├── __init__.py ├── templates.py # Template management └── filter_builder.py # Query building ``` -### Bad: Large Single File +### ❌ Bad: Large Single File ``` cli_tool/commands/ └── ssm.py # 600+ lines, multiple concerns ``` -### Better: Split Structure +### ❌ Bad: Missing Subdirectories for Command Groups ``` -cli_tool/ssm/ -├── commands/ -│ ├── connect.py # Connection commands -│ ├── database.py # Database management -│ └── instance.py # Instance management -├── core/ -│ ├── session.py # SSM session logic -│ └── port_forwarder.py # Port forwarding logic -└── utils/ - └── hosts_manager.py # /etc/hosts management +cli_tool/ssm/commands/ +├── database_connect.py # Should be database/connect.py +├── database_list.py # Should be database/list.py +├── instance_shell.py # Should be instance/shell.py +└── instance_list.py # Should be instance/list.py ``` ## Testing Structure @@ -244,5 +297,8 @@ Each feature module should have: 1. **Consistency** - Easy to find code across features 2. **Maintainability** - Clear separation of concerns 3. **Testability** - Business logic isolated from CLI -4. **Scalability** - Easy to add new subcommands +4. **Scalability** - Easy to add new subcommands (just add new file) 5. **Onboarding** - New developers know where to look +6. **Small Files** - Each command file is 20-100 lines (easy to understand) +7. **Git Friendly** - Less merge conflicts with small, focused files +8. **Discoverability** - File structure mirrors CLI structure diff --git a/cli_tool/commands/ssm.py b/cli_tool/commands/ssm.py index ac9fc6a..5ed9285 100644 --- a/cli_tool/commands/ssm.py +++ b/cli_tool/commands/ssm.py @@ -1,641 +1,25 @@ """AWS Systems Manager Session Manager commands""" -import threading -import time - import click -from rich.console import Console -from rich.table import Table - -from cli_tool.ssm.config import SSMConfigManager -from cli_tool.ssm.hosts_manager import HostsManager -from cli_tool.ssm.port_forwarder import PortForwarder -from cli_tool.ssm.session import SSMSession - -# Backward compatibility alias -SocatManager = PortForwarder -console = Console() +from cli_tool.ssm.commands import ( + register_database_commands, + register_forward_command, + register_hosts_commands, + register_instance_commands, + register_shortcuts, +) @click.group() def ssm(): - """AWS Systems Manager Session Manager commands""" - pass - - -# ============================================================================ -# Database Connection Commands -# ============================================================================ - - -@ssm.command("connect") -@click.argument("name", required=False) -@click.option("--no-hosts", is_flag=True, help="Disable hostname forwarding (use localhost)") -def connect_database(name, no_hosts): - """Connect to a configured database (uses hostname forwarding by default)""" - config_manager = SSMConfigManager() - databases = config_manager.list_databases() - - if not databases: - console.print("[red]No databases configured[/red]") - console.print("\nAdd a database with: devo ssm add-db") - return - - # If no name provided, show interactive menu - if not name: - console.print("[cyan]Select database to connect:[/cyan]\n") - - db_list = list(databases.keys()) - - # Show options - for i, db_name in enumerate(db_list, 1): - db = databases[db_name] - profile_text = db.get("profile", "default") - console.print(f" {i}. {db_name} ({db['host']}) [dim](profile: {profile_text})[/dim]") - - console.print(f" {len(db_list) + 1}. Connect to all databases") - console.print() - - # Get user choice - try: - choice = click.prompt("Enter number", type=int, default=1) - - if choice < 1 or choice > len(db_list) + 1: - console.print("[red]Invalid selection[/red]") - return - - # Connect to all - if choice == len(db_list) + 1: - _connect_all_databases(config_manager, databases, no_hosts) - return - - # Connect to selected database - name = db_list[choice - 1] - - except (KeyboardInterrupt, click.Abort): - console.print("\n[yellow]Cancelled[/yellow]") - return - - # Single database connection - db_config = config_manager.get_database(name) - - if not db_config: - console.print(f"[red]Database '{name}' not found in config[/red]") - console.print("\nAvailable databases:") - for db_name in databases.keys(): - console.print(f" - {db_name}") - return - - # Check if hostname forwarding is configured - local_address = db_config.get("local_address", "127.0.0.1") - use_hostname_forwarding = (local_address != "127.0.0.1") and not no_hosts - - if use_hostname_forwarding: - # Validate that hostname is in /etc/hosts - hosts_manager = HostsManager() - managed_entries = hosts_manager.get_managed_entries() - hostname_in_hosts = any(host == db_config["host"] for _, host in managed_entries) - - if not hostname_in_hosts: - console.print(f"[yellow]Warning: {db_config['host']} not found in /etc/hosts[/yellow]") - console.print("[dim]Run 'devo ssm hosts setup' to configure hostname forwarding[/dim]\n") - - # Ask if user wants to continue with localhost - if click.confirm("Continue with localhost forwarding instead?", default=True): - use_hostname_forwarding = False - else: - console.print("[yellow]Cancelled[/yellow]") - return - - if use_hostname_forwarding: - # Use hostname forwarding - profile_text = db_config.get("profile", "default") - console.print(f"[cyan]Connecting to {name}...[/cyan]") - console.print(f"[dim]Hostname: {db_config['host']}[/dim]") - console.print(f"[dim]Profile: {profile_text}[/dim]") - console.print(f"[dim]Forwarding: {local_address}:{db_config['port']} -> 127.0.0.1:{db_config['local_port']}[/dim]") - console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") - - port_forwarder = PortForwarder() - - try: - # Start port forwarding - port_forwarder.start_forward(local_address=local_address, local_port=db_config["port"], target_port=db_config["local_port"]) - - # Start SSM session - exit_code = SSMSession.start_port_forwarding_to_remote( - bastion=db_config["bastion"], - host=db_config["host"], - port=db_config["port"], - local_port=db_config["local_port"], - region=db_config["region"], - profile=db_config.get("profile"), - ) - - if exit_code != 0: - console.print("[red]SSM session failed[/red]") - - except KeyboardInterrupt: - console.print("\n[cyan]Stopping...[/cyan]") - except Exception as e: - console.print(f"\n[red]Error: {e}[/red]") - return - finally: - port_forwarder.stop_all() - console.print("[green]Connection closed[/green]") - else: - # Use localhost forwarding (simple mode) - profile_text = db_config.get("profile", "default") - - if local_address != "127.0.0.1" and no_hosts: - console.print("[yellow]Hostname forwarding disabled (using localhost)[/yellow]") - elif local_address == "127.0.0.1": - console.print("[yellow]Hostname forwarding not configured (using localhost)[/yellow]") - console.print("[dim]Run 'devo ssm hosts setup' to enable hostname forwarding[/dim]\n") - - console.print(f"[cyan]Connecting to {name}...[/cyan]") - console.print(f"[dim]{db_config['host']}:{db_config['port']} -> localhost:{db_config['local_port']}[/dim]") - console.print(f"[dim]Profile: {profile_text}[/dim]") - console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") - - try: - exit_code = SSMSession.start_port_forwarding_to_remote( - bastion=db_config["bastion"], - host=db_config["host"], - port=db_config["port"], - local_port=db_config["local_port"], - region=db_config["region"], - profile=db_config.get("profile"), - ) - if exit_code != 0: - console.print("[red]Connection failed[/red]") - except KeyboardInterrupt: - console.print("\n[green]Connection closed[/green]") - - -def _connect_all_databases(config_manager, databases, no_hosts): - """Helper function to connect to all databases""" - console.print("[cyan]Starting all connections...[/cyan]\n") - - # Validate hosts setup - hosts_manager = HostsManager() - managed_entries = hosts_manager.get_managed_entries() - managed_hosts = {host for _, host in managed_entries} - - port_forwarder = PortForwarder() - threads = [] - - # Track used local ports to avoid conflicts - used_local_ports = set() - next_available_port = 15432 # Start from a high port to avoid conflicts - - def get_unique_local_port(preferred_port): - """Get a unique local port, incrementing if already in use""" - nonlocal next_available_port - if preferred_port not in used_local_ports: - used_local_ports.add(preferred_port) - return preferred_port - # Find next available port - while next_available_port in used_local_ports: - next_available_port += 1 - used_local_ports.add(next_available_port) - result = next_available_port - next_available_port += 1 - return result - - def start_connection(name, db_config, actual_local_port): - """Thread function to start a single connection""" - try: - SSMSession.start_port_forwarding_to_remote( - bastion=db_config["bastion"], - host=db_config["host"], - port=db_config["port"], - local_port=actual_local_port, - region=db_config["region"], - profile=db_config.get("profile"), - ) - except Exception as e: - console.print(f"[red]✗[/red] {name}: {e}") - - for name, db_config in databases.items(): - local_address = db_config.get("local_address", "127.0.0.1") - use_hostname_forwarding = (local_address != "127.0.0.1") and not no_hosts - - # Check if hostname is in /etc/hosts - if use_hostname_forwarding and db_config["host"] not in managed_hosts: - console.print(f"[yellow]⚠[/yellow] {name}: Not in /etc/hosts (run 'devo ssm hosts setup')") - continue - - if use_hostname_forwarding: - # Get unique local port for this connection - preferred_local_port = db_config.get("local_port", db_config["port"]) - actual_local_port = get_unique_local_port(preferred_local_port) - - profile_text = db_config.get("profile", "default") - port_info = f"{local_address}:{db_config['port']}" - if actual_local_port != preferred_local_port: - port_info += f" [dim](local: {actual_local_port})[/dim]" - - console.print(f"[green]✓[/green] {name}: {db_config['host']} ({port_info}) [dim](profile: {profile_text})[/dim]") - - try: - # Start port forwarding from loopback alias to the actual local port - port_forwarder.start_forward(local_address=local_address, local_port=db_config["port"], target_port=actual_local_port) - - # Start SSM session in separate thread with the unique local port - thread = threading.Thread(target=start_connection, args=(name, db_config, actual_local_port), daemon=True) - thread.start() - threads.append((name, thread)) - - # Small delay to avoid overwhelming the system - time.sleep(0.5) - - except Exception as e: - console.print(f"[red]✗[/red] {name}: {e}") - else: - console.print(f"[yellow]⚠[/yellow] {name}: Hostname forwarding not configured (skipping)") - - if not threads: - console.print("\n[yellow]No databases to connect[/yellow]") - console.print("Run: devo ssm hosts setup") - return - - console.print("\n[green]All connections started![/green]") - console.print("[yellow]Press Ctrl+C to stop all connections[/yellow]\n") - - try: - # Keep main thread alive while connections are active - while any(thread.is_alive() for _, thread in threads): - time.sleep(1) - except KeyboardInterrupt: - console.print("\n[cyan]Stopping all connections...[/cyan]") - port_forwarder.stop_all() - console.print("[green]All connections closed[/green]") - - -@ssm.command("list") -def list_databases(): - """List configured databases""" - config_manager = SSMConfigManager() - databases = config_manager.list_databases() - - if not databases: - console.print("[yellow]No databases configured[/yellow]") - console.print("\nAdd a database with: devo ssm add-db") - return - - table = Table(title="Configured Databases") - table.add_column("Name", style="cyan") - table.add_column("Host", style="white") - table.add_column("Port", style="green") - table.add_column("Profile", style="yellow") - - for name, db in databases.items(): - table.add_row(name, db["host"], str(db["port"]), db.get("profile", "-")) - - console.print(table) - - -@ssm.command("add-db") -@click.option("--name", required=True, help="Database configuration name") -@click.option("--bastion", required=True, help="Bastion instance ID") -@click.option("--host", required=True, help="Database host/endpoint") -@click.option("--port", required=True, type=int, help="Database port") -@click.option("--local-port", type=int, help="Local port (default: same as remote)") -@click.option("--region", default="us-east-1", help="AWS region") -@click.option("--profile", help="AWS profile") -def add_database(name, bastion, host, port, local_port, region, profile): - """Add a database configuration""" - config_manager = SSMConfigManager() - - config_manager.add_database(name=name, bastion=bastion, host=host, port=port, region=region, profile=profile, local_port=local_port) - - console.print(f"[green]Database '{name}' added successfully[/green]") - console.print(f"\nConnect with: devo ssm connect {name}") - - -@ssm.command("remove-db") -@click.argument("name") -def remove_database(name): - """Remove a database configuration""" - config_manager = SSMConfigManager() - - if config_manager.remove_database(name): - console.print(f"[green]Database '{name}' removed[/green]") - else: - console.print(f"[red]Database '{name}' not found[/red]") - - -# ============================================================================ -# Instance Connection Commands -# ============================================================================ - - -@ssm.command("shell") -@click.argument("name") -def connect_instance(name): - """Connect to a configured instance via interactive shell""" - config_manager = SSMConfigManager() - instance_config = config_manager.get_instance(name) - - if not instance_config: - console.print(f"[red]Instance '{name}' not found in config[/red]") - console.print("\nAvailable instances:") - for inst_name in config_manager.list_instances().keys(): - console.print(f" - {inst_name}") - return - - console.print(f"[cyan]Connecting to {name} ({instance_config['instance_id']})...[/cyan]") - console.print("[yellow]Type 'exit' to close the session[/yellow]\n") - - try: - SSMSession.start_session(instance_id=instance_config["instance_id"], region=instance_config["region"], profile=instance_config.get("profile")) - except KeyboardInterrupt: - console.print("\n[green]Session closed[/green]") - - -@ssm.command("list-instances") -def list_instances(): - """List configured instances""" - config_manager = SSMConfigManager() - instances = config_manager.list_instances() - - if not instances: - console.print("[yellow]No instances configured[/yellow]") - console.print("\nAdd an instance with: devo ssm add-instance") - return - - table = Table(title="Configured Instances") - table.add_column("Name", style="cyan") - table.add_column("Instance ID", style="white") - table.add_column("Region", style="green") - table.add_column("Profile", style="yellow") - - for name, inst in instances.items(): - table.add_row(name, inst["instance_id"], inst["region"], inst.get("profile", "-")) - - console.print(table) - - -@ssm.command("add-instance") -@click.option("--name", required=True, help="Instance configuration name") -@click.option("--instance-id", required=True, help="EC2 instance ID") -@click.option("--region", default="us-east-1", help="AWS region") -@click.option("--profile", help="AWS profile") -def add_instance(name, instance_id, region, profile): - """Add an instance configuration""" - config_manager = SSMConfigManager() - - config_manager.add_instance(name=name, instance_id=instance_id, region=region, profile=profile) - - console.print(f"[green]Instance '{name}' added successfully[/green]") - console.print(f"\nConnect with: devo ssm shell {name}") - - -@ssm.command("remove-instance") -@click.argument("name") -def remove_instance(name): - """Remove an instance configuration""" - config_manager = SSMConfigManager() - - if config_manager.remove_instance(name): - console.print(f"[green]Instance '{name}' removed[/green]") - else: - console.print(f"[red]Instance '{name}' not found[/red]") - - -# ============================================================================ -# Config Management Commands -# ============================================================================ - - -@ssm.command("export") -@click.argument("output_path") -def export_config(output_path): - """Export SSM configuration to a file""" - config_manager = SSMConfigManager() - - try: - config_manager.export_config(output_path) - console.print(f"[green]Configuration exported to {output_path}[/green]") - except Exception as e: - console.print(f"[red]Error exporting config: {e}[/red]") - - -@ssm.command("import") -@click.argument("input_path") -@click.option("--merge", is_flag=True, help="Merge with existing config instead of replacing") -def import_config(input_path, merge): - """Import SSM configuration from a file""" - config_manager = SSMConfigManager() - - try: - config_manager.import_config(input_path, merge=merge) - action = "merged" if merge else "imported" - console.print(f"[green]Configuration {action} from {input_path}[/green]") - except FileNotFoundError: - console.print(f"[red]Config file not found: {input_path}[/red]") - except Exception as e: - console.print(f"[red]Error importing config: {e}[/red]") - - -# ============================================================================ -# Manual Connection Commands (without config) -# ============================================================================ - - -@ssm.command("forward") -@click.option("--bastion", required=True, help="Bastion instance ID") -@click.option("--host", required=True, help="Database/service endpoint") -@click.option("--port", default=5432, type=int, help="Remote port") -@click.option("--local-port", type=int, help="Local port (default: same as remote)") -@click.option("--region", default="us-east-1", help="AWS region") -@click.option("--profile", help="AWS profile (optional, uses default if not specified)") -def forward_manual(bastion, host, port, local_port, region, profile): - """Manual port forwarding (without using config) - - Note: This command allows --profile for one-off connections. - For saved database configurations, profile is stored in config. - """ - if not local_port: - local_port = port - - console.print(f"[cyan]Forwarding {host}:{port} -> localhost:{local_port}[/cyan]") - console.print(f"[dim]Via bastion: {bastion}[/dim]") - if profile: - console.print(f"[dim]Profile: {profile}[/dim]") - console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") - - try: - SSMSession.start_port_forwarding_to_remote(bastion=bastion, host=host, port=port, local_port=local_port, region=region, profile=profile) - except KeyboardInterrupt: - console.print("\n[green]Connection closed[/green]") - - -# ============================================================================ -# /etc/hosts Management Commands (subgroup) -# ============================================================================ - - -@ssm.group("hosts") -def hosts(): - """Manage /etc/hosts entries for hostname forwarding""" - pass - - -@hosts.command("setup") -def hosts_setup(): - """Setup /etc/hosts entries for all configured databases""" - config_manager = SSMConfigManager() - hosts_manager = HostsManager() - databases = config_manager.list_databases() - - if not databases: - console.print("[yellow]No databases configured[/yellow]") - return - - console.print("[cyan]Setting up /etc/hosts entries...[/cyan]\n") - - success_count = 0 - error_count = 0 - - # Track used local_port values to detect conflicts - used_local_ports = {} - next_available_port = 15432 # Start from a high port for auto-assignment - - for name, db_config in databases.items(): - # Get or assign loopback IP - if "local_address" not in db_config or db_config["local_address"] == "127.0.0.1": - # Assign new loopback IP - local_address = hosts_manager.get_next_loopback_ip() - - # Update config - config = config_manager.load() - config["databases"][name]["local_address"] = local_address - config_manager.save(config) - else: - local_address = db_config["local_address"] - - # Check for local_port conflicts (multiple DBs trying to use same local port) - local_port = db_config.get("local_port", db_config["port"]) - - if local_port in used_local_ports: - console.print(f"[yellow]⚠[/yellow] {name}: Local port {local_port} already used by {used_local_ports[local_port]}") - console.print(f"[dim] Assigning unique local port {next_available_port}...[/dim]") - - # Assign unique local port - local_port = next_available_port - next_available_port += 1 - - # Update config with new local_port - config = config_manager.load() - config["databases"][name]["local_port"] = local_port - config_manager.save(config) - - used_local_ports[local_port] = name - - # Add to /etc/hosts - try: - hosts_manager.add_entry(local_address, db_config["host"]) - console.print(f"[green]✓[/green] {name}: {db_config['host']} -> {local_address}:{db_config['port']} (local: {local_port})") - success_count += 1 - except Exception as e: - console.print(f"[red]✗[/red] {name}: {e}") - error_count += 1 - - # Show appropriate completion message - if error_count > 0 and success_count == 0: - console.print("\n[red]Setup failed![/red]") - console.print("[yellow]All entries failed. Please run your terminal as Administrator.[/yellow]") - elif error_count > 0: - console.print("\n[yellow]Setup partially complete[/yellow]") - console.print(f"[dim]{success_count} succeeded, {error_count} failed[/dim]") - else: - console.print("\n[green]Setup complete![/green]") - console.print("\n[dim]Your microservices can now use the real hostnames in their configuration.[/dim]") - - -@hosts.command("list") -def hosts_list(): - """List all /etc/hosts entries managed by Devo CLI""" - hosts_manager = HostsManager() - entries = hosts_manager.get_managed_entries() - - if not entries: - console.print("[yellow]No managed entries in /etc/hosts[/yellow]") - console.print("\nRun: devo ssm hosts setup") - return - - table = Table(title="Managed /etc/hosts Entries") - table.add_column("IP", style="cyan") - table.add_column("Hostname", style="white") - - for ip, hostname in entries: - table.add_row(ip, hostname) - - console.print(table) - - -@hosts.command("clear") -@click.confirmation_option(prompt="Remove all Devo CLI entries from /etc/hosts?") -def hosts_clear(): - """Remove all Devo CLI managed entries from /etc/hosts""" - hosts_manager = HostsManager() - - try: - hosts_manager.clear_all() - console.print("[green]All managed entries removed from /etc/hosts[/green]") - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - - -@hosts.command("add") -@click.argument("name") -def hosts_add_single(name): - """Add a single database hostname to /etc/hosts""" - config_manager = SSMConfigManager() - hosts_manager = HostsManager() - - db_config = config_manager.get_database(name) - - if not db_config: - console.print(f"[red]Database '{name}' not found[/red]") - return - - # Get or assign loopback IP - if "local_address" not in db_config or db_config["local_address"] == "127.0.0.1": - local_address = hosts_manager.get_next_loopback_ip() - - # Update config - config = config_manager.load() - config["databases"][name]["local_address"] = local_address - config_manager.save(config) - else: - local_address = db_config["local_address"] - - try: - hosts_manager.add_entry(local_address, db_config["host"]) - console.print(f"[green]Added {db_config['host']} -> {local_address}[/green]") - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - - -@hosts.command("remove") -@click.argument("name") -def hosts_remove_single(name): - """Remove a database hostname from /etc/hosts""" - config_manager = SSMConfigManager() - hosts_manager = HostsManager() - - db_config = config_manager.get_database(name) + """AWS Systems Manager Session Manager commands""" + pass - if not db_config: - console.print(f"[red]Database '{name}' not found[/red]") - return - try: - hosts_manager.remove_entry(db_config["host"]) - console.print(f"[green]Removed {db_config['host']} from /etc/hosts[/green]") - except Exception as e: - console.print(f"[red]Error: {e}[/red]") +# Register all subcommands +register_database_commands(ssm) +register_instance_commands(ssm) +register_forward_command(ssm) +register_hosts_commands(ssm) +register_shortcuts(ssm) diff --git a/cli_tool/ssm/__init__.py b/cli_tool/ssm/__init__.py index 20c636b..b7eb515 100644 --- a/cli_tool/ssm/__init__.py +++ b/cli_tool/ssm/__init__.py @@ -1,11 +1,10 @@ """AWS Systems Manager Session Manager integration""" -from cli_tool.ssm.config import SSMConfigManager -from cli_tool.ssm.hosts_manager import HostsManager -from cli_tool.ssm.port_forwarder import PortForwarder -from cli_tool.ssm.session import SSMSession +# Backward compatibility imports +from cli_tool.ssm.core import PortForwarder, SSMConfigManager, SSMSession +from cli_tool.ssm.utils import HostsManager -# Backward compatibility +# Backward compatibility alias SocatManager = PortForwarder __all__ = ["SSMConfigManager", "SSMSession", "HostsManager", "PortForwarder", "SocatManager"] diff --git a/cli_tool/ssm/commands/__init__.py b/cli_tool/ssm/commands/__init__.py new file mode 100644 index 0000000..887c688 --- /dev/null +++ b/cli_tool/ssm/commands/__init__.py @@ -0,0 +1,15 @@ +"""SSM commands module.""" + +from cli_tool.ssm.commands.database import register_database_commands +from cli_tool.ssm.commands.forward import register_forward_command +from cli_tool.ssm.commands.hosts import register_hosts_commands +from cli_tool.ssm.commands.instance import register_instance_commands +from cli_tool.ssm.commands.shortcuts import register_shortcuts + +__all__ = [ + "register_database_commands", + "register_instance_commands", + "register_forward_command", + "register_hosts_commands", + "register_shortcuts", +] diff --git a/cli_tool/ssm/commands/database/__init__.py b/cli_tool/ssm/commands/database/__init__.py new file mode 100644 index 0000000..075a88f --- /dev/null +++ b/cli_tool/ssm/commands/database/__init__.py @@ -0,0 +1,23 @@ +"""Database connection commands for SSM.""" + +import click + +from cli_tool.ssm.commands.database.add import add_database +from cli_tool.ssm.commands.database.connect import connect_database +from cli_tool.ssm.commands.database.list import list_databases +from cli_tool.ssm.commands.database.remove import remove_database + + +def register_database_commands(ssm_group): + """Register database-related commands to the SSM group.""" + + @ssm_group.group("database") + def database(): + """Manage database connections""" + pass + + # Register all database commands + database.add_command(connect_database, "connect") + database.add_command(list_databases, "list") + database.add_command(add_database, "add") + database.add_command(remove_database, "remove") diff --git a/cli_tool/ssm/commands/database/add.py b/cli_tool/ssm/commands/database/add.py new file mode 100644 index 0000000..1977892 --- /dev/null +++ b/cli_tool/ssm/commands/database/add.py @@ -0,0 +1,26 @@ +"""Database add command.""" + +import click +from rich.console import Console + +from cli_tool.ssm.core import SSMConfigManager + +console = Console() + + +@click.command() +@click.option("--name", required=True, help="Database configuration name") +@click.option("--bastion", required=True, help="Bastion instance ID") +@click.option("--host", required=True, help="Database host/endpoint") +@click.option("--port", required=True, type=int, help="Database port") +@click.option("--local-port", type=int, help="Local port (default: same as remote)") +@click.option("--region", default="us-east-1", help="AWS region") +@click.option("--profile", help="AWS profile") +def add_database(name, bastion, host, port, local_port, region, profile): + """Add a database configuration""" + config_manager = SSMConfigManager() + + config_manager.add_database(name=name, bastion=bastion, host=host, port=port, region=region, profile=profile, local_port=local_port) + + console.print(f"[green]Database '{name}' added successfully[/green]") + console.print(f"\nConnect with: devo ssm connect {name}") diff --git a/cli_tool/ssm/commands/database/connect.py b/cli_tool/ssm/commands/database/connect.py new file mode 100644 index 0000000..3537776 --- /dev/null +++ b/cli_tool/ssm/commands/database/connect.py @@ -0,0 +1,229 @@ +"""Database connect command.""" + +import threading +import time + +import click +from rich.console import Console + +from cli_tool.ssm.core import PortForwarder, SSMConfigManager, SSMSession +from cli_tool.ssm.utils import HostsManager + +console = Console() + + +def _connect_all_databases(config_manager, databases, no_hosts): + """Helper function to connect to all databases""" + console.print("[cyan]Starting all connections...[/cyan]\n") + + hosts_manager = HostsManager() + managed_entries = hosts_manager.get_managed_entries() + managed_hosts = {host for _, host in managed_entries} + + port_forwarder = PortForwarder() + threads = [] + + used_local_ports = set() + next_available_port = 15432 + + def get_unique_local_port(preferred_port): + nonlocal next_available_port + if preferred_port not in used_local_ports: + used_local_ports.add(preferred_port) + return preferred_port + while next_available_port in used_local_ports: + next_available_port += 1 + used_local_ports.add(next_available_port) + result = next_available_port + next_available_port += 1 + return result + + def start_connection(name, db_config, actual_local_port): + try: + SSMSession.start_port_forwarding_to_remote( + bastion=db_config["bastion"], + host=db_config["host"], + port=db_config["port"], + local_port=actual_local_port, + region=db_config["region"], + profile=db_config.get("profile"), + ) + except Exception as e: + console.print(f"[red]✗[/red] {name}: {e}") + + for name, db_config in databases.items(): + local_address = db_config.get("local_address", "127.0.0.1") + use_hostname_forwarding = (local_address != "127.0.0.1") and not no_hosts + + if use_hostname_forwarding and db_config["host"] not in managed_hosts: + console.print(f"[yellow]⚠[/yellow] {name}: Not in /etc/hosts (run 'devo ssm hosts setup')") + continue + + if use_hostname_forwarding: + preferred_local_port = db_config.get("local_port", db_config["port"]) + actual_local_port = get_unique_local_port(preferred_local_port) + + profile_text = db_config.get("profile", "default") + port_info = f"{local_address}:{db_config['port']}" + if actual_local_port != preferred_local_port: + port_info += f" [dim](local: {actual_local_port})[/dim]" + + console.print(f"[green]✓[/green] {name}: {db_config['host']} ({port_info}) [dim](profile: {profile_text})[/dim]") + + try: + port_forwarder.start_forward(local_address=local_address, local_port=db_config["port"], target_port=actual_local_port) + thread = threading.Thread(target=start_connection, args=(name, db_config, actual_local_port), daemon=True) + thread.start() + threads.append((name, thread)) + time.sleep(0.5) + except Exception as e: + console.print(f"[red]✗[/red] {name}: {e}") + else: + console.print(f"[yellow]⚠[/yellow] {name}: Hostname forwarding not configured (skipping)") + + if not threads: + console.print("\n[yellow]No databases to connect[/yellow]") + console.print("Run: devo ssm hosts setup") + return + + console.print("\n[green]All connections started![/green]") + console.print("[yellow]Press Ctrl+C to stop all connections[/yellow]\n") + + try: + while any(thread.is_alive() for _, thread in threads): + time.sleep(1) + except KeyboardInterrupt: + console.print("\n[cyan]Stopping all connections...[/cyan]") + port_forwarder.stop_all() + console.print("[green]All connections closed[/green]") + + +@click.command() +@click.argument("name", required=False) +@click.option("--no-hosts", is_flag=True, help="Disable hostname forwarding (use localhost)") +def connect_database(name, no_hosts): + """Connect to a configured database (uses hostname forwarding by default)""" + config_manager = SSMConfigManager() + databases = config_manager.list_databases() + + if not databases: + console.print("[red]No databases configured[/red]") + console.print("\nAdd a database with: devo ssm database add") + return + + if not name: + console.print("[cyan]Select database to connect:[/cyan]\n") + db_list = list(databases.keys()) + + for i, db_name in enumerate(db_list, 1): + db = databases[db_name] + profile_text = db.get("profile", "default") + console.print(f" {i}. {db_name} ({db['host']}) [dim](profile: {profile_text})[/dim]") + + console.print(f" {len(db_list) + 1}. Connect to all databases") + console.print() + + try: + choice = click.prompt("Enter number", type=int, default=1) + + if choice < 1 or choice > len(db_list) + 1: + console.print("[red]Invalid selection[/red]") + return + + if choice == len(db_list) + 1: + _connect_all_databases(config_manager, databases, no_hosts) + return + + name = db_list[choice - 1] + + except (KeyboardInterrupt, click.Abort): + console.print("\n[yellow]Cancelled[/yellow]") + return + + db_config = config_manager.get_database(name) + + if not db_config: + console.print(f"[red]Database '{name}' not found in config[/red]") + console.print("\nAvailable databases:") + for db_name in databases.keys(): + console.print(f" - {db_name}") + return + + local_address = db_config.get("local_address", "127.0.0.1") + use_hostname_forwarding = (local_address != "127.0.0.1") and not no_hosts + + if use_hostname_forwarding: + hosts_manager = HostsManager() + managed_entries = hosts_manager.get_managed_entries() + hostname_in_hosts = any(host == db_config["host"] for _, host in managed_entries) + + if not hostname_in_hosts: + console.print(f"[yellow]Warning: {db_config['host']} not found in /etc/hosts[/yellow]") + console.print("[dim]Run 'devo ssm hosts setup' to configure hostname forwarding[/dim]\n") + + if click.confirm("Continue with localhost forwarding instead?", default=True): + use_hostname_forwarding = False + else: + console.print("[yellow]Cancelled[/yellow]") + return + + if use_hostname_forwarding: + profile_text = db_config.get("profile", "default") + console.print(f"[cyan]Connecting to {name}...[/cyan]") + console.print(f"[dim]Hostname: {db_config['host']}[/dim]") + console.print(f"[dim]Profile: {profile_text}[/dim]") + console.print(f"[dim]Forwarding: {local_address}:{db_config['port']} -> 127.0.0.1:{db_config['local_port']}[/dim]") + console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") + + port_forwarder = PortForwarder() + + try: + port_forwarder.start_forward(local_address=local_address, local_port=db_config["port"], target_port=db_config["local_port"]) + + exit_code = SSMSession.start_port_forwarding_to_remote( + bastion=db_config["bastion"], + host=db_config["host"], + port=db_config["port"], + local_port=db_config["local_port"], + region=db_config["region"], + profile=db_config.get("profile"), + ) + + if exit_code != 0: + console.print("[red]SSM session failed[/red]") + + except KeyboardInterrupt: + console.print("\n[cyan]Stopping...[/cyan]") + except Exception as e: + console.print(f"\n[red]Error: {e}[/red]") + return + finally: + port_forwarder.stop_all() + console.print("[green]Connection closed[/green]") + else: + profile_text = db_config.get("profile", "default") + + if local_address != "127.0.0.1" and no_hosts: + console.print("[yellow]Hostname forwarding disabled (using localhost)[/yellow]") + elif local_address == "127.0.0.1": + console.print("[yellow]Hostname forwarding not configured (using localhost)[/yellow]") + console.print("[dim]Run 'devo ssm hosts setup' to enable hostname forwarding[/dim]\n") + + console.print(f"[cyan]Connecting to {name}...[/cyan]") + console.print(f"[dim]{db_config['host']}:{db_config['port']} -> localhost:{db_config['local_port']}[/dim]") + console.print(f"[dim]Profile: {profile_text}[/dim]") + console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") + + try: + exit_code = SSMSession.start_port_forwarding_to_remote( + bastion=db_config["bastion"], + host=db_config["host"], + port=db_config["port"], + local_port=db_config["local_port"], + region=db_config["region"], + profile=db_config.get("profile"), + ) + if exit_code != 0: + console.print("[red]Connection failed[/red]") + except KeyboardInterrupt: + console.print("\n[green]Connection closed[/green]") diff --git a/cli_tool/ssm/commands/database/list.py b/cli_tool/ssm/commands/database/list.py new file mode 100644 index 0000000..f207bcf --- /dev/null +++ b/cli_tool/ssm/commands/database/list.py @@ -0,0 +1,32 @@ +"""Database list command.""" + +import click +from rich.console import Console +from rich.table import Table + +from cli_tool.ssm.core import SSMConfigManager + +console = Console() + + +@click.command() +def list_databases(): + """List configured databases""" + config_manager = SSMConfigManager() + databases = config_manager.list_databases() + + if not databases: + console.print("[yellow]No databases configured[/yellow]") + console.print("\nAdd a database with: devo ssm database add") + return + + table = Table(title="Configured Databases") + table.add_column("Name", style="cyan") + table.add_column("Host", style="white") + table.add_column("Port", style="green") + table.add_column("Profile", style="yellow") + + for name, db in databases.items(): + table.add_row(name, db["host"], str(db["port"]), db.get("profile", "-")) + + console.print(table) diff --git a/cli_tool/ssm/commands/database/remove.py b/cli_tool/ssm/commands/database/remove.py new file mode 100644 index 0000000..93c2c54 --- /dev/null +++ b/cli_tool/ssm/commands/database/remove.py @@ -0,0 +1,20 @@ +"""Database remove command.""" + +import click +from rich.console import Console + +from cli_tool.ssm.core import SSMConfigManager + +console = Console() + + +@click.command() +@click.argument("name") +def remove_database(name): + """Remove a database configuration""" + config_manager = SSMConfigManager() + + if config_manager.remove_database(name): + console.print(f"[green]Database '{name}' removed[/green]") + else: + console.print(f"[red]Database '{name}' not found[/red]") diff --git a/cli_tool/ssm/commands/forward.py b/cli_tool/ssm/commands/forward.py new file mode 100644 index 0000000..f06b92e --- /dev/null +++ b/cli_tool/ssm/commands/forward.py @@ -0,0 +1,44 @@ +"""Manual port forwarding command for SSM.""" + +import click +from rich.console import Console + +from cli_tool.ssm.core import SSMSession + +console = Console() + + +def register_forward_command(ssm_group): + """Register manual port forwarding command to the SSM group.""" + + @ssm_group.command("forward") + @click.option("--bastion", required=True, help="Bastion instance ID") + @click.option("--host", required=True, help="Database/service endpoint") + @click.option("--port", default=5432, type=int, help="Remote port") + @click.option("--local-port", type=int, help="Local port (default: same as remote)") + @click.option("--region", default="us-east-1", help="AWS region") + @click.option("--profile", help="AWS profile (optional, uses default if not specified)") + def forward_manual(bastion, host, port, local_port, region, profile): + """Manual port forwarding (without using config) + + Note: This command allows --profile for one-off connections. + For saved database configurations, profile is stored in config. + """ + if not local_port: + local_port = port + + console.print(f"[cyan]Forwarding {host}:{port} -> localhost:{local_port}[/cyan]") + console.print(f"[dim]Via bastion: {bastion}[/dim]") + if profile: + console.print(f"[dim]Profile: {profile}[/dim]") + console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") + + try: + SSMSession.start_port_forwarding_to_remote(bastion=bastion, host=host, port=port, local_port=local_port, region=region, profile=profile) + except KeyboardInterrupt: + console.print("\n[green]Connection closed[/green]") + + +def forward_command(): + """Return forward command registration function.""" + return register_forward_command diff --git a/cli_tool/ssm/commands/hosts/__init__.py b/cli_tool/ssm/commands/hosts/__init__.py new file mode 100644 index 0000000..b5f5879 --- /dev/null +++ b/cli_tool/ssm/commands/hosts/__init__.py @@ -0,0 +1,25 @@ +"""/etc/hosts management commands for SSM.""" + +import click + +from cli_tool.ssm.commands.hosts.add import hosts_add_single +from cli_tool.ssm.commands.hosts.clear import hosts_clear +from cli_tool.ssm.commands.hosts.list import hosts_list +from cli_tool.ssm.commands.hosts.remove import hosts_remove_single +from cli_tool.ssm.commands.hosts.setup import hosts_setup + + +def register_hosts_commands(ssm_group): + """Register /etc/hosts management commands to the SSM group.""" + + @ssm_group.group("hosts") + def hosts(): + """Manage /etc/hosts entries for hostname forwarding""" + pass + + # Register all hosts commands + hosts.add_command(hosts_setup, "setup") + hosts.add_command(hosts_list, "list") + hosts.add_command(hosts_clear, "clear") + hosts.add_command(hosts_add_single, "add") + hosts.add_command(hosts_remove_single, "remove") diff --git a/cli_tool/ssm/commands/hosts/add.py b/cli_tool/ssm/commands/hosts/add.py new file mode 100644 index 0000000..862cb08 --- /dev/null +++ b/cli_tool/ssm/commands/hosts/add.py @@ -0,0 +1,40 @@ +"""Hosts add command.""" + +import click +from rich.console import Console + +from cli_tool.ssm.core import SSMConfigManager +from cli_tool.ssm.utils import HostsManager + +console = Console() + + +@click.command() +@click.argument("name") +def hosts_add_single(name): + """Add a single database hostname to /etc/hosts""" + config_manager = SSMConfigManager() + hosts_manager = HostsManager() + + db_config = config_manager.get_database(name) + + if not db_config: + console.print(f"[red]Database '{name}' not found[/red]") + return + + # Get or assign loopback IP + if "local_address" not in db_config or db_config["local_address"] == "127.0.0.1": + local_address = hosts_manager.get_next_loopback_ip() + + # Update config + config = config_manager.load() + config["databases"][name]["local_address"] = local_address + config_manager.save(config) + else: + local_address = db_config["local_address"] + + try: + hosts_manager.add_entry(local_address, db_config["host"]) + console.print(f"[green]Added {db_config['host']} -> {local_address}[/green]") + except Exception as e: + console.print(f"[red]Error: {e}[/red]") diff --git a/cli_tool/ssm/commands/hosts/clear.py b/cli_tool/ssm/commands/hosts/clear.py new file mode 100644 index 0000000..662307d --- /dev/null +++ b/cli_tool/ssm/commands/hosts/clear.py @@ -0,0 +1,21 @@ +"""Hosts clear command.""" + +import click +from rich.console import Console + +from cli_tool.ssm.utils import HostsManager + +console = Console() + + +@click.command() +@click.confirmation_option(prompt="Remove all Devo CLI entries from /etc/hosts?") +def hosts_clear(): + """Remove all Devo CLI managed entries from /etc/hosts""" + hosts_manager = HostsManager() + + try: + hosts_manager.clear_all() + console.print("[green]All managed entries removed from /etc/hosts[/green]") + except Exception as e: + console.print(f"[red]Error: {e}[/red]") diff --git a/cli_tool/ssm/commands/hosts/list.py b/cli_tool/ssm/commands/hosts/list.py new file mode 100644 index 0000000..e914fd2 --- /dev/null +++ b/cli_tool/ssm/commands/hosts/list.py @@ -0,0 +1,30 @@ +"""Hosts list command.""" + +import click +from rich.console import Console +from rich.table import Table + +from cli_tool.ssm.utils import HostsManager + +console = Console() + + +@click.command() +def hosts_list(): + """List all /etc/hosts entries managed by Devo CLI""" + hosts_manager = HostsManager() + entries = hosts_manager.get_managed_entries() + + if not entries: + console.print("[yellow]No managed entries in /etc/hosts[/yellow]") + console.print("\nRun: devo ssm hosts setup") + return + + table = Table(title="Managed /etc/hosts Entries") + table.add_column("IP", style="cyan") + table.add_column("Hostname", style="white") + + for ip, hostname in entries: + table.add_row(ip, hostname) + + console.print(table) diff --git a/cli_tool/ssm/commands/hosts/remove.py b/cli_tool/ssm/commands/hosts/remove.py new file mode 100644 index 0000000..fb43394 --- /dev/null +++ b/cli_tool/ssm/commands/hosts/remove.py @@ -0,0 +1,29 @@ +"""Hosts remove command.""" + +import click +from rich.console import Console + +from cli_tool.ssm.core import SSMConfigManager +from cli_tool.ssm.utils import HostsManager + +console = Console() + + +@click.command() +@click.argument("name") +def hosts_remove_single(name): + """Remove a database hostname from /etc/hosts""" + config_manager = SSMConfigManager() + hosts_manager = HostsManager() + + db_config = config_manager.get_database(name) + + if not db_config: + console.print(f"[red]Database '{name}' not found[/red]") + return + + try: + hosts_manager.remove_entry(db_config["host"]) + console.print(f"[green]Removed {db_config['host']} from /etc/hosts[/green]") + except Exception as e: + console.print(f"[red]Error: {e}[/red]") diff --git a/cli_tool/ssm/commands/hosts/setup.py b/cli_tool/ssm/commands/hosts/setup.py new file mode 100644 index 0000000..f7c5992 --- /dev/null +++ b/cli_tool/ssm/commands/hosts/setup.py @@ -0,0 +1,81 @@ +"""Hosts setup command.""" + +import click +from rich.console import Console + +from cli_tool.ssm.core import SSMConfigManager +from cli_tool.ssm.utils import HostsManager + +console = Console() + + +@click.command() +def hosts_setup(): + """Setup /etc/hosts entries for all configured databases""" + config_manager = SSMConfigManager() + hosts_manager = HostsManager() + databases = config_manager.list_databases() + + if not databases: + console.print("[yellow]No databases configured[/yellow]") + return + + console.print("[cyan]Setting up /etc/hosts entries...[/cyan]\n") + + success_count = 0 + error_count = 0 + + # Track used local_port values to detect conflicts + used_local_ports = {} + next_available_port = 15432 # Start from a high port for auto-assignment + + for name, db_config in databases.items(): + # Get or assign loopback IP + if "local_address" not in db_config or db_config["local_address"] == "127.0.0.1": + # Assign new loopback IP + local_address = hosts_manager.get_next_loopback_ip() + + # Update config + config = config_manager.load() + config["databases"][name]["local_address"] = local_address + config_manager.save(config) + else: + local_address = db_config["local_address"] + + # Check for local_port conflicts (multiple DBs trying to use same local port) + local_port = db_config.get("local_port", db_config["port"]) + + if local_port in used_local_ports: + console.print(f"[yellow]⚠[/yellow] {name}: Local port {local_port} already used by {used_local_ports[local_port]}") + console.print(f"[dim] Assigning unique local port {next_available_port}...[/dim]") + + # Assign unique local port + local_port = next_available_port + next_available_port += 1 + + # Update config with new local_port + config = config_manager.load() + config["databases"][name]["local_port"] = local_port + config_manager.save(config) + + used_local_ports[local_port] = name + + # Add to /etc/hosts + try: + hosts_manager.add_entry(local_address, db_config["host"]) + console.print(f"[green]✓[/green] {name}: {db_config['host']} -> {local_address}:{db_config['port']} (local: {local_port})") + success_count += 1 + except Exception as e: + console.print(f"[red]✗[/red] {name}: {e}") + error_count += 1 + + # Show appropriate completion message + if error_count > 0 and success_count == 0: + console.print("\n[red]Setup failed![/red]") + console.print("[yellow]All entries failed. Please run your terminal as Administrator.[/yellow]") + elif error_count > 0: + console.print("\n[yellow]Setup partially complete[/yellow]") + console.print(f"[dim]{success_count} succeeded, {error_count} failed[/dim]") + else: + console.print("\n[green]Setup complete![/green]") + console.print("\n[dim]Your microservices can now use the real hostnames in their configuration.[/dim]") diff --git a/cli_tool/ssm/commands/instance/__init__.py b/cli_tool/ssm/commands/instance/__init__.py new file mode 100644 index 0000000..9bd808e --- /dev/null +++ b/cli_tool/ssm/commands/instance/__init__.py @@ -0,0 +1,23 @@ +"""Instance connection commands for SSM.""" + +import click + +from cli_tool.ssm.commands.instance.add import add_instance +from cli_tool.ssm.commands.instance.list import list_instances +from cli_tool.ssm.commands.instance.remove import remove_instance +from cli_tool.ssm.commands.instance.shell import connect_instance + + +def register_instance_commands(ssm_group): + """Register instance-related commands to the SSM group.""" + + @ssm_group.group("instance") + def instance(): + """Manage EC2 instance connections""" + pass + + # Register all instance commands + instance.add_command(connect_instance, "shell") + instance.add_command(list_instances, "list") + instance.add_command(add_instance, "add") + instance.add_command(remove_instance, "remove") diff --git a/cli_tool/ssm/commands/instance/add.py b/cli_tool/ssm/commands/instance/add.py new file mode 100644 index 0000000..1f37c83 --- /dev/null +++ b/cli_tool/ssm/commands/instance/add.py @@ -0,0 +1,23 @@ +"""Instance add command.""" + +import click +from rich.console import Console + +from cli_tool.ssm.core import SSMConfigManager + +console = Console() + + +@click.command() +@click.option("--name", required=True, help="Instance configuration name") +@click.option("--instance-id", required=True, help="EC2 instance ID") +@click.option("--region", default="us-east-1", help="AWS region") +@click.option("--profile", help="AWS profile") +def add_instance(name, instance_id, region, profile): + """Add an instance configuration""" + config_manager = SSMConfigManager() + + config_manager.add_instance(name=name, instance_id=instance_id, region=region, profile=profile) + + console.print(f"[green]Instance '{name}' added successfully[/green]") + console.print(f"\nConnect with: devo ssm shell {name}") diff --git a/cli_tool/ssm/commands/instance/list.py b/cli_tool/ssm/commands/instance/list.py new file mode 100644 index 0000000..a3ef7eb --- /dev/null +++ b/cli_tool/ssm/commands/instance/list.py @@ -0,0 +1,32 @@ +"""Instance list command.""" + +import click +from rich.console import Console +from rich.table import Table + +from cli_tool.ssm.core import SSMConfigManager + +console = Console() + + +@click.command() +def list_instances(): + """List configured instances""" + config_manager = SSMConfigManager() + instances = config_manager.list_instances() + + if not instances: + console.print("[yellow]No instances configured[/yellow]") + console.print("\nAdd an instance with: devo ssm instance add") + return + + table = Table(title="Configured Instances") + table.add_column("Name", style="cyan") + table.add_column("Instance ID", style="white") + table.add_column("Region", style="green") + table.add_column("Profile", style="yellow") + + for name, inst in instances.items(): + table.add_row(name, inst["instance_id"], inst["region"], inst.get("profile", "-")) + + console.print(table) diff --git a/cli_tool/ssm/commands/instance/remove.py b/cli_tool/ssm/commands/instance/remove.py new file mode 100644 index 0000000..e5fadb4 --- /dev/null +++ b/cli_tool/ssm/commands/instance/remove.py @@ -0,0 +1,20 @@ +"""Instance remove command.""" + +import click +from rich.console import Console + +from cli_tool.ssm.core import SSMConfigManager + +console = Console() + + +@click.command() +@click.argument("name") +def remove_instance(name): + """Remove an instance configuration""" + config_manager = SSMConfigManager() + + if config_manager.remove_instance(name): + console.print(f"[green]Instance '{name}' removed[/green]") + else: + console.print(f"[red]Instance '{name}' not found[/red]") diff --git a/cli_tool/ssm/commands/instance/shell.py b/cli_tool/ssm/commands/instance/shell.py new file mode 100644 index 0000000..5d1621d --- /dev/null +++ b/cli_tool/ssm/commands/instance/shell.py @@ -0,0 +1,31 @@ +"""Instance shell command.""" + +import click +from rich.console import Console + +from cli_tool.ssm.core import SSMConfigManager, SSMSession + +console = Console() + + +@click.command() +@click.argument("name") +def connect_instance(name): + """Connect to a configured instance via interactive shell""" + config_manager = SSMConfigManager() + instance_config = config_manager.get_instance(name) + + if not instance_config: + console.print(f"[red]Instance '{name}' not found in config[/red]") + console.print("\nAvailable instances:") + for inst_name in config_manager.list_instances().keys(): + console.print(f" - {inst_name}") + return + + console.print(f"[cyan]Connecting to {name} ({instance_config['instance_id']})...[/cyan]") + console.print("[yellow]Type 'exit' to close the session[/yellow]\n") + + try: + SSMSession.start_session(instance_id=instance_config["instance_id"], region=instance_config["region"], profile=instance_config.get("profile")) + except KeyboardInterrupt: + console.print("\n[green]Session closed[/green]") diff --git a/cli_tool/ssm/commands/shortcuts.py b/cli_tool/ssm/commands/shortcuts.py new file mode 100644 index 0000000..f951043 --- /dev/null +++ b/cli_tool/ssm/commands/shortcuts.py @@ -0,0 +1,42 @@ +"""Shortcuts for most used SSM commands.""" + +import click + + +def register_shortcuts(ssm_group): + """Register shortcut commands for most used operations.""" + + @ssm_group.command("connect", hidden=False) + @click.argument("name", required=False) + @click.option("--no-hosts", is_flag=True, help="Disable hostname forwarding (use localhost)") + @click.pass_context + def connect_shortcut(ctx, name, no_hosts): + """Shortcut for 'devo ssm database connect'""" + # Get the database group and invoke connect + database_group = None + for cmd_name, cmd in ssm_group.commands.items(): + if cmd_name == "database": + database_group = cmd + break + + if database_group: + connect_cmd = database_group.commands.get("connect") + if connect_cmd: + ctx.invoke(connect_cmd, name=name, no_hosts=no_hosts) + + @ssm_group.command("shell", hidden=False) + @click.argument("name") + @click.pass_context + def shell_shortcut(ctx, name): + """Shortcut for 'devo ssm instance shell'""" + # Get the instance group and invoke shell + instance_group = None + for cmd_name, cmd in ssm_group.commands.items(): + if cmd_name == "instance": + instance_group = cmd + break + + if instance_group: + shell_cmd = instance_group.commands.get("shell") + if shell_cmd: + ctx.invoke(shell_cmd, name=name) diff --git a/cli_tool/ssm/core/__init__.py b/cli_tool/ssm/core/__init__.py new file mode 100644 index 0000000..fa6b669 --- /dev/null +++ b/cli_tool/ssm/core/__init__.py @@ -0,0 +1,7 @@ +"""SSM core business logic.""" + +from cli_tool.ssm.core.config import SSMConfigManager +from cli_tool.ssm.core.port_forwarder import PortForwarder +from cli_tool.ssm.core.session import SSMSession + +__all__ = ["SSMConfigManager", "PortForwarder", "SSMSession"] diff --git a/cli_tool/ssm/config.py b/cli_tool/ssm/core/config.py similarity index 100% rename from cli_tool/ssm/config.py rename to cli_tool/ssm/core/config.py diff --git a/cli_tool/ssm/port_forwarder.py b/cli_tool/ssm/core/port_forwarder.py similarity index 100% rename from cli_tool/ssm/port_forwarder.py rename to cli_tool/ssm/core/port_forwarder.py diff --git a/cli_tool/ssm/session.py b/cli_tool/ssm/core/session.py similarity index 100% rename from cli_tool/ssm/session.py rename to cli_tool/ssm/core/session.py diff --git a/cli_tool/ssm/utils/__init__.py b/cli_tool/ssm/utils/__init__.py new file mode 100644 index 0000000..3d7c66b --- /dev/null +++ b/cli_tool/ssm/utils/__init__.py @@ -0,0 +1,5 @@ +"""SSM utilities.""" + +from cli_tool.ssm.utils.hosts_manager import HostsManager + +__all__ = ["HostsManager"] diff --git a/cli_tool/ssm/hosts_manager.py b/cli_tool/ssm/utils/hosts_manager.py similarity index 100% rename from cli_tool/ssm/hosts_manager.py rename to cli_tool/ssm/utils/hosts_manager.py From 5594a9b913f9d22fe89ebf61b381d7eec1339285 Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Sun, 1 Mar 2026 23:21:19 -0500 Subject: [PATCH 02/37] refactor(aws-login): restructure into modular command and core architecture - Reorganize aws_login module into commands/ and core/ subdirectories - Move command implementations (login, refresh, set_default, setup) to commands/ package - Move core utilities (config, credentials) to core/ package - Add new list command for displaying available profiles - Remove legacy list.py and status.py files - Update module imports and __init__.py files for new structure - Add python-performance-optimization and python-testing-patterns skills - Update code-organization steering documentation - Improve aws_profile utility and version_check functionality - Enhance aws_login README and command initialization --- .../python-performance-optimization/SKILL.md | 874 ++++++++++++++ .kiro/skills/python-testing-patterns/SKILL.md | 1050 +++++++++++++++++ .kiro/steering/code-organization.md | 4 +- cli_tool/aws_login/README.md | 131 +- cli_tool/aws_login/__init__.py | 26 +- cli_tool/aws_login/command.py | 187 +-- cli_tool/aws_login/commands/__init__.py | 15 + .../aws_login/{status.py => commands/list.py} | 14 +- cli_tool/aws_login/{ => commands}/login.py | 10 +- cli_tool/aws_login/{ => commands}/refresh.py | 4 +- .../aws_login/{ => commands}/set_default.py | 2 +- cli_tool/aws_login/{ => commands}/setup.py | 4 +- cli_tool/aws_login/core/__init__.py | 29 + cli_tool/aws_login/{ => core}/config.py | 0 cli_tool/aws_login/{ => core}/credentials.py | 2 +- cli_tool/aws_login/list.py | 35 - cli_tool/commands/upgrade.py | 3 +- cli_tool/utils/aws_profile.py | 2 +- cli_tool/utils/version_check.py | 3 +- 19 files changed, 2179 insertions(+), 216 deletions(-) create mode 100644 .kiro/skills/python-performance-optimization/SKILL.md create mode 100644 .kiro/skills/python-testing-patterns/SKILL.md create mode 100644 cli_tool/aws_login/commands/__init__.py rename cli_tool/aws_login/{status.py => commands/list.py} (81%) rename cli_tool/aws_login/{ => commands}/login.py (94%) rename cli_tool/aws_login/{ => commands}/refresh.py (97%) rename cli_tool/aws_login/{ => commands}/set_default.py (99%) rename cli_tool/aws_login/{ => commands}/setup.py (99%) create mode 100644 cli_tool/aws_login/core/__init__.py rename cli_tool/aws_login/{ => core}/config.py (100%) rename cli_tool/aws_login/{ => core}/credentials.py (98%) delete mode 100644 cli_tool/aws_login/list.py diff --git a/.kiro/skills/python-performance-optimization/SKILL.md b/.kiro/skills/python-performance-optimization/SKILL.md new file mode 100644 index 0000000..68b233e --- /dev/null +++ b/.kiro/skills/python-performance-optimization/SKILL.md @@ -0,0 +1,874 @@ +--- +name: python-performance-optimization +description: Profile and optimize Python code using cProfile, memory profilers, and performance best practices. Use when debugging slow Python code, optimizing bottlenecks, or improving application performance. +--- + +# Python Performance Optimization + +Comprehensive guide to profiling, analyzing, and optimizing Python code for better performance, including CPU profiling, memory optimization, and implementation best practices. + +## When to Use This Skill + +- Identifying performance bottlenecks in Python applications +- Reducing application latency and response times +- Optimizing CPU-intensive operations +- Reducing memory consumption and memory leaks +- Improving database query performance +- Optimizing I/O operations +- Speeding up data processing pipelines +- Implementing high-performance algorithms +- Profiling production applications + +## Core Concepts + +### 1. Profiling Types + +- **CPU Profiling**: Identify time-consuming functions +- **Memory Profiling**: Track memory allocation and leaks +- **Line Profiling**: Profile at line-by-line granularity +- **Call Graph**: Visualize function call relationships + +### 2. Performance Metrics + +- **Execution Time**: How long operations take +- **Memory Usage**: Peak and average memory consumption +- **CPU Utilization**: Processor usage patterns +- **I/O Wait**: Time spent on I/O operations + +### 3. Optimization Strategies + +- **Algorithmic**: Better algorithms and data structures +- **Implementation**: More efficient code patterns +- **Parallelization**: Multi-threading/processing +- **Caching**: Avoid redundant computation +- **Native Extensions**: C/Rust for critical paths + +## Quick Start + +### Basic Timing + +```python +import time + +def measure_time(): + """Simple timing measurement.""" + start = time.time() + + # Your code here + result = sum(range(1000000)) + + elapsed = time.time() - start + print(f"Execution time: {elapsed:.4f} seconds") + return result + +# Better: use timeit for accurate measurements +import timeit + +execution_time = timeit.timeit( + "sum(range(1000000))", + number=100 +) +print(f"Average time: {execution_time/100:.6f} seconds") +``` + +## Profiling Tools + +### Pattern 1: cProfile - CPU Profiling + +```python +import cProfile +import pstats +from pstats import SortKey + +def slow_function(): + """Function to profile.""" + total = 0 + for i in range(1000000): + total += i + return total + +def another_function(): + """Another function.""" + return [i**2 for i in range(100000)] + +def main(): + """Main function to profile.""" + result1 = slow_function() + result2 = another_function() + return result1, result2 + +# Profile the code +if __name__ == "__main__": + profiler = cProfile.Profile() + profiler.enable() + + main() + + profiler.disable() + + # Print stats + stats = pstats.Stats(profiler) + stats.sort_stats(SortKey.CUMULATIVE) + stats.print_stats(10) # Top 10 functions + + # Save to file for later analysis + stats.dump_stats("profile_output.prof") +``` + +**Command-line profiling:** + +```bash +# Profile a script +python -m cProfile -o output.prof script.py + +# View results +python -m pstats output.prof +# In pstats: +# sort cumtime +# stats 10 +``` + +### Pattern 2: line_profiler - Line-by-Line Profiling + +```python +# Install: pip install line-profiler + +# Add @profile decorator (line_profiler provides this) +@profile +def process_data(data): + """Process data with line profiling.""" + result = [] + for item in data: + processed = item * 2 + result.append(processed) + return result + +# Run with: +# kernprof -l -v script.py +``` + +**Manual line profiling:** + +```python +from line_profiler import LineProfiler + +def process_data(data): + """Function to profile.""" + result = [] + for item in data: + processed = item * 2 + result.append(processed) + return result + +if __name__ == "__main__": + lp = LineProfiler() + lp.add_function(process_data) + + data = list(range(100000)) + + lp_wrapper = lp(process_data) + lp_wrapper(data) + + lp.print_stats() +``` + +### Pattern 3: memory_profiler - Memory Usage + +```python +# Install: pip install memory-profiler + +from memory_profiler import profile + +@profile +def memory_intensive(): + """Function that uses lots of memory.""" + # Create large list + big_list = [i for i in range(1000000)] + + # Create large dict + big_dict = {i: i**2 for i in range(100000)} + + # Process data + result = sum(big_list) + + return result + +if __name__ == "__main__": + memory_intensive() + +# Run with: +# python -m memory_profiler script.py +``` + +### Pattern 4: py-spy - Production Profiling + +```bash +# Install: pip install py-spy + +# Profile a running Python process +py-spy top --pid 12345 + +# Generate flamegraph +py-spy record -o profile.svg --pid 12345 + +# Profile a script +py-spy record -o profile.svg -- python script.py + +# Dump current call stack +py-spy dump --pid 12345 +``` + +## Optimization Patterns + +### Pattern 5: List Comprehensions vs Loops + +```python +import timeit + +# Slow: Traditional loop +def slow_squares(n): + """Create list of squares using loop.""" + result = [] + for i in range(n): + result.append(i**2) + return result + +# Fast: List comprehension +def fast_squares(n): + """Create list of squares using comprehension.""" + return [i**2 for i in range(n)] + +# Benchmark +n = 100000 + +slow_time = timeit.timeit(lambda: slow_squares(n), number=100) +fast_time = timeit.timeit(lambda: fast_squares(n), number=100) + +print(f"Loop: {slow_time:.4f}s") +print(f"Comprehension: {fast_time:.4f}s") +print(f"Speedup: {slow_time/fast_time:.2f}x") + +# Even faster for simple operations: map +def faster_squares(n): + """Use map for even better performance.""" + return list(map(lambda x: x**2, range(n))) +``` + +### Pattern 6: Generator Expressions for Memory + +```python +import sys + +def list_approach(): + """Memory-intensive list.""" + data = [i**2 for i in range(1000000)] + return sum(data) + +def generator_approach(): + """Memory-efficient generator.""" + data = (i**2 for i in range(1000000)) + return sum(data) + +# Memory comparison +list_data = [i for i in range(1000000)] +gen_data = (i for i in range(1000000)) + +print(f"List size: {sys.getsizeof(list_data)} bytes") +print(f"Generator size: {sys.getsizeof(gen_data)} bytes") + +# Generators use constant memory regardless of size +``` + +### Pattern 7: String Concatenation + +```python +import timeit + +def slow_concat(items): + """Slow string concatenation.""" + result = "" + for item in items: + result += str(item) + return result + +def fast_concat(items): + """Fast string concatenation with join.""" + return "".join(str(item) for item in items) + +def faster_concat(items): + """Even faster with list.""" + parts = [str(item) for item in items] + return "".join(parts) + +items = list(range(10000)) + +# Benchmark +slow = timeit.timeit(lambda: slow_concat(items), number=100) +fast = timeit.timeit(lambda: fast_concat(items), number=100) +faster = timeit.timeit(lambda: faster_concat(items), number=100) + +print(f"Concatenation (+): {slow:.4f}s") +print(f"Join (generator): {fast:.4f}s") +print(f"Join (list): {faster:.4f}s") +``` + +### Pattern 8: Dictionary Lookups vs List Searches + +```python +import timeit + +# Create test data +size = 10000 +items = list(range(size)) +lookup_dict = {i: i for i in range(size)} + +def list_search(items, target): + """O(n) search in list.""" + return target in items + +def dict_search(lookup_dict, target): + """O(1) search in dict.""" + return target in lookup_dict + +target = size - 1 # Worst case for list + +# Benchmark +list_time = timeit.timeit( + lambda: list_search(items, target), + number=1000 +) +dict_time = timeit.timeit( + lambda: dict_search(lookup_dict, target), + number=1000 +) + +print(f"List search: {list_time:.6f}s") +print(f"Dict search: {dict_time:.6f}s") +print(f"Speedup: {list_time/dict_time:.0f}x") +``` + +### Pattern 9: Local Variable Access + +```python +import timeit + +# Global variable (slow) +GLOBAL_VALUE = 100 + +def use_global(): + """Access global variable.""" + total = 0 + for i in range(10000): + total += GLOBAL_VALUE + return total + +def use_local(): + """Use local variable.""" + local_value = 100 + total = 0 + for i in range(10000): + total += local_value + return total + +# Local is faster +global_time = timeit.timeit(use_global, number=1000) +local_time = timeit.timeit(use_local, number=1000) + +print(f"Global access: {global_time:.4f}s") +print(f"Local access: {local_time:.4f}s") +print(f"Speedup: {global_time/local_time:.2f}x") +``` + +### Pattern 10: Function Call Overhead + +```python +import timeit + +def calculate_inline(): + """Inline calculation.""" + total = 0 + for i in range(10000): + total += i * 2 + 1 + return total + +def helper_function(x): + """Helper function.""" + return x * 2 + 1 + +def calculate_with_function(): + """Calculation with function calls.""" + total = 0 + for i in range(10000): + total += helper_function(i) + return total + +# Inline is faster due to no call overhead +inline_time = timeit.timeit(calculate_inline, number=1000) +function_time = timeit.timeit(calculate_with_function, number=1000) + +print(f"Inline: {inline_time:.4f}s") +print(f"Function calls: {function_time:.4f}s") +``` + +## Advanced Optimization + +### Pattern 11: NumPy for Numerical Operations + +```python +import timeit +import numpy as np + +def python_sum(n): + """Sum using pure Python.""" + return sum(range(n)) + +def numpy_sum(n): + """Sum using NumPy.""" + return np.arange(n).sum() + +n = 1000000 + +python_time = timeit.timeit(lambda: python_sum(n), number=100) +numpy_time = timeit.timeit(lambda: numpy_sum(n), number=100) + +print(f"Python: {python_time:.4f}s") +print(f"NumPy: {numpy_time:.4f}s") +print(f"Speedup: {python_time/numpy_time:.2f}x") + +# Vectorized operations +def python_multiply(): + """Element-wise multiplication in Python.""" + a = list(range(100000)) + b = list(range(100000)) + return [x * y for x, y in zip(a, b)] + +def numpy_multiply(): + """Vectorized multiplication in NumPy.""" + a = np.arange(100000) + b = np.arange(100000) + return a * b + +py_time = timeit.timeit(python_multiply, number=100) +np_time = timeit.timeit(numpy_multiply, number=100) + +print(f"\nPython multiply: {py_time:.4f}s") +print(f"NumPy multiply: {np_time:.4f}s") +print(f"Speedup: {py_time/np_time:.2f}x") +``` + +### Pattern 12: Caching with functools.lru_cache + +```python +from functools import lru_cache +import timeit + +def fibonacci_slow(n): + """Recursive fibonacci without caching.""" + if n < 2: + return n + return fibonacci_slow(n-1) + fibonacci_slow(n-2) + +@lru_cache(maxsize=None) +def fibonacci_fast(n): + """Recursive fibonacci with caching.""" + if n < 2: + return n + return fibonacci_fast(n-1) + fibonacci_fast(n-2) + +# Massive speedup for recursive algorithms +n = 30 + +slow_time = timeit.timeit(lambda: fibonacci_slow(n), number=1) +fast_time = timeit.timeit(lambda: fibonacci_fast(n), number=1000) + +print(f"Without cache (1 run): {slow_time:.4f}s") +print(f"With cache (1000 runs): {fast_time:.4f}s") + +# Cache info +print(f"Cache info: {fibonacci_fast.cache_info()}") +``` + +### Pattern 13: Using **slots** for Memory + +```python +import sys + +class RegularClass: + """Regular class with __dict__.""" + def __init__(self, x, y, z): + self.x = x + self.y = y + self.z = z + +class SlottedClass: + """Class with __slots__ for memory efficiency.""" + __slots__ = ['x', 'y', 'z'] + + def __init__(self, x, y, z): + self.x = x + self.y = y + self.z = z + +# Memory comparison +regular = RegularClass(1, 2, 3) +slotted = SlottedClass(1, 2, 3) + +print(f"Regular class size: {sys.getsizeof(regular)} bytes") +print(f"Slotted class size: {sys.getsizeof(slotted)} bytes") + +# Significant savings with many instances +regular_objects = [RegularClass(i, i+1, i+2) for i in range(10000)] +slotted_objects = [SlottedClass(i, i+1, i+2) for i in range(10000)] + +print(f"\nMemory for 10000 regular objects: ~{sys.getsizeof(regular) * 10000} bytes") +print(f"Memory for 10000 slotted objects: ~{sys.getsizeof(slotted) * 10000} bytes") +``` + +### Pattern 14: Multiprocessing for CPU-Bound Tasks + +```python +import multiprocessing as mp +import time + +def cpu_intensive_task(n): + """CPU-intensive calculation.""" + return sum(i**2 for i in range(n)) + +def sequential_processing(): + """Process tasks sequentially.""" + start = time.time() + results = [cpu_intensive_task(1000000) for _ in range(4)] + elapsed = time.time() - start + return elapsed, results + +def parallel_processing(): + """Process tasks in parallel.""" + start = time.time() + with mp.Pool(processes=4) as pool: + results = pool.map(cpu_intensive_task, [1000000] * 4) + elapsed = time.time() - start + return elapsed, results + +if __name__ == "__main__": + seq_time, seq_results = sequential_processing() + par_time, par_results = parallel_processing() + + print(f"Sequential: {seq_time:.2f}s") + print(f"Parallel: {par_time:.2f}s") + print(f"Speedup: {seq_time/par_time:.2f}x") +``` + +### Pattern 15: Async I/O for I/O-Bound Tasks + +```python +import asyncio +import aiohttp +import time +import requests + +urls = [ + "https://httpbin.org/delay/1", + "https://httpbin.org/delay/1", + "https://httpbin.org/delay/1", + "https://httpbin.org/delay/1", +] + +def synchronous_requests(): + """Synchronous HTTP requests.""" + start = time.time() + results = [] + for url in urls: + response = requests.get(url) + results.append(response.status_code) + elapsed = time.time() - start + return elapsed, results + +async def async_fetch(session, url): + """Async HTTP request.""" + async with session.get(url) as response: + return response.status + +async def asynchronous_requests(): + """Asynchronous HTTP requests.""" + start = time.time() + async with aiohttp.ClientSession() as session: + tasks = [async_fetch(session, url) for url in urls] + results = await asyncio.gather(*tasks) + elapsed = time.time() - start + return elapsed, results + +# Async is much faster for I/O-bound work +sync_time, sync_results = synchronous_requests() +async_time, async_results = asyncio.run(asynchronous_requests()) + +print(f"Synchronous: {sync_time:.2f}s") +print(f"Asynchronous: {async_time:.2f}s") +print(f"Speedup: {sync_time/async_time:.2f}x") +``` + +## Database Optimization + +### Pattern 16: Batch Database Operations + +```python +import sqlite3 +import time + +def create_db(): + """Create test database.""" + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)") + return conn + +def slow_inserts(conn, count): + """Insert records one at a time.""" + start = time.time() + cursor = conn.cursor() + for i in range(count): + cursor.execute("INSERT INTO users (name) VALUES (?)", (f"User {i}",)) + conn.commit() # Commit each insert + elapsed = time.time() - start + return elapsed + +def fast_inserts(conn, count): + """Batch insert with single commit.""" + start = time.time() + cursor = conn.cursor() + data = [(f"User {i}",) for i in range(count)] + cursor.executemany("INSERT INTO users (name) VALUES (?)", data) + conn.commit() # Single commit + elapsed = time.time() - start + return elapsed + +# Benchmark +conn1 = create_db() +slow_time = slow_inserts(conn1, 1000) + +conn2 = create_db() +fast_time = fast_inserts(conn2, 1000) + +print(f"Individual inserts: {slow_time:.4f}s") +print(f"Batch insert: {fast_time:.4f}s") +print(f"Speedup: {slow_time/fast_time:.2f}x") +``` + +### Pattern 17: Query Optimization + +```python +# Use indexes for frequently queried columns +""" +-- Slow: No index +SELECT * FROM users WHERE email = 'user@example.com'; + +-- Fast: With index +CREATE INDEX idx_users_email ON users(email); +SELECT * FROM users WHERE email = 'user@example.com'; +""" + +# Use query planning +import sqlite3 + +conn = sqlite3.connect("example.db") +cursor = conn.cursor() + +# Analyze query performance +cursor.execute("EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = ?", ("test@example.com",)) +print(cursor.fetchall()) + +# Use SELECT only needed columns +# Slow: SELECT * +# Fast: SELECT id, name +``` + +## Memory Optimization + +### Pattern 18: Detecting Memory Leaks + +```python +import tracemalloc +import gc + +def memory_leak_example(): + """Example that leaks memory.""" + leaked_objects = [] + + for i in range(100000): + # Objects added but never removed + leaked_objects.append([i] * 100) + + # In real code, this would be an unintended reference + +def track_memory_usage(): + """Track memory allocations.""" + tracemalloc.start() + + # Take snapshot before + snapshot1 = tracemalloc.take_snapshot() + + # Run code + memory_leak_example() + + # Take snapshot after + snapshot2 = tracemalloc.take_snapshot() + + # Compare + top_stats = snapshot2.compare_to(snapshot1, 'lineno') + + print("Top 10 memory allocations:") + for stat in top_stats[:10]: + print(stat) + + tracemalloc.stop() + +# Monitor memory +track_memory_usage() + +# Force garbage collection +gc.collect() +``` + +### Pattern 19: Iterators vs Lists + +```python +import sys + +def process_file_list(filename): + """Load entire file into memory.""" + with open(filename) as f: + lines = f.readlines() # Loads all lines + return sum(1 for line in lines if line.strip()) + +def process_file_iterator(filename): + """Process file line by line.""" + with open(filename) as f: + return sum(1 for line in f if line.strip()) + +# Iterator uses constant memory +# List loads entire file into memory +``` + +### Pattern 20: Weakref for Caches + +```python +import weakref + +class CachedResource: + """Resource that can be garbage collected.""" + def __init__(self, data): + self.data = data + +# Regular cache prevents garbage collection +regular_cache = {} + +def get_resource_regular(key): + """Get resource from regular cache.""" + if key not in regular_cache: + regular_cache[key] = CachedResource(f"Data for {key}") + return regular_cache[key] + +# Weak reference cache allows garbage collection +weak_cache = weakref.WeakValueDictionary() + +def get_resource_weak(key): + """Get resource from weak cache.""" + resource = weak_cache.get(key) + if resource is None: + resource = CachedResource(f"Data for {key}") + weak_cache[key] = resource + return resource + +# When no strong references exist, objects can be GC'd +``` + +## Benchmarking Tools + +### Custom Benchmark Decorator + +```python +import time +from functools import wraps + +def benchmark(func): + """Decorator to benchmark function execution.""" + @wraps(func) + def wrapper(*args, **kwargs): + start = time.perf_counter() + result = func(*args, **kwargs) + elapsed = time.perf_counter() - start + print(f"{func.__name__} took {elapsed:.6f} seconds") + return result + return wrapper + +@benchmark +def slow_function(): + """Function to benchmark.""" + time.sleep(0.5) + return sum(range(1000000)) + +result = slow_function() +``` + +### Performance Testing with pytest-benchmark + +```python +# Install: pip install pytest-benchmark + +def test_list_comprehension(benchmark): + """Benchmark list comprehension.""" + result = benchmark(lambda: [i**2 for i in range(10000)]) + assert len(result) == 10000 + +def test_map_function(benchmark): + """Benchmark map function.""" + result = benchmark(lambda: list(map(lambda x: x**2, range(10000)))) + assert len(result) == 10000 + +# Run with: pytest test_performance.py --benchmark-compare +``` + +## Best Practices + +1. **Profile before optimizing** - Measure to find real bottlenecks +2. **Focus on hot paths** - Optimize code that runs most frequently +3. **Use appropriate data structures** - Dict for lookups, set for membership +4. **Avoid premature optimization** - Clarity first, then optimize +5. **Use built-in functions** - They're implemented in C +6. **Cache expensive computations** - Use lru_cache +7. **Batch I/O operations** - Reduce system calls +8. **Use generators** for large datasets +9. **Consider NumPy** for numerical operations +10. **Profile production code** - Use py-spy for live systems + +## Common Pitfalls + +- Optimizing without profiling +- Using global variables unnecessarily +- Not using appropriate data structures +- Creating unnecessary copies of data +- Not using connection pooling for databases +- Ignoring algorithmic complexity +- Over-optimizing rare code paths +- Not considering memory usage + +## Resources + +- **cProfile**: Built-in CPU profiler +- **memory_profiler**: Memory usage profiling +- **line_profiler**: Line-by-line profiling +- **py-spy**: Sampling profiler for production +- **NumPy**: High-performance numerical computing +- **Cython**: Compile Python to C +- **PyPy**: Alternative Python interpreter with JIT + +## Performance Checklist + +- [ ] Profiled code to identify bottlenecks +- [ ] Used appropriate data structures +- [ ] Implemented caching where beneficial +- [ ] Optimized database queries +- [ ] Used generators for large datasets +- [ ] Considered multiprocessing for CPU-bound tasks +- [ ] Used async I/O for I/O-bound tasks +- [ ] Minimized function call overhead in hot loops +- [ ] Checked for memory leaks +- [ ] Benchmarked before and after optimization diff --git a/.kiro/skills/python-testing-patterns/SKILL.md b/.kiro/skills/python-testing-patterns/SKILL.md new file mode 100644 index 0000000..6693894 --- /dev/null +++ b/.kiro/skills/python-testing-patterns/SKILL.md @@ -0,0 +1,1050 @@ +--- +name: python-testing-patterns +description: Implement comprehensive testing strategies with pytest, fixtures, mocking, and test-driven development. Use when writing Python tests, setting up test suites, or implementing testing best practices. +--- + +# Python Testing Patterns + +Comprehensive guide to implementing robust testing strategies in Python using pytest, fixtures, mocking, parameterization, and test-driven development practices. + +## When to Use This Skill + +- Writing unit tests for Python code +- Setting up test suites and test infrastructure +- Implementing test-driven development (TDD) +- Creating integration tests for APIs and services +- Mocking external dependencies and services +- Testing async code and concurrent operations +- Setting up continuous testing in CI/CD +- Implementing property-based testing +- Testing database operations +- Debugging failing tests + +## Core Concepts + +### 1. Test Types + +- **Unit Tests**: Test individual functions/classes in isolation +- **Integration Tests**: Test interaction between components +- **Functional Tests**: Test complete features end-to-end +- **Performance Tests**: Measure speed and resource usage + +### 2. Test Structure (AAA Pattern) + +- **Arrange**: Set up test data and preconditions +- **Act**: Execute the code under test +- **Assert**: Verify the results + +### 3. Test Coverage + +- Measure what code is exercised by tests +- Identify untested code paths +- Aim for meaningful coverage, not just high percentages + +### 4. Test Isolation + +- Tests should be independent +- No shared state between tests +- Each test should clean up after itself + +## Quick Start + +```python +# test_example.py +def add(a, b): + return a + b + +def test_add(): + """Basic test example.""" + result = add(2, 3) + assert result == 5 + +def test_add_negative(): + """Test with negative numbers.""" + assert add(-1, 1) == 0 + +# Run with: pytest test_example.py +``` + +## Fundamental Patterns + +### Pattern 1: Basic pytest Tests + +```python +# test_calculator.py +import pytest + +class Calculator: + """Simple calculator for testing.""" + + def add(self, a: float, b: float) -> float: + return a + b + + def subtract(self, a: float, b: float) -> float: + return a - b + + def multiply(self, a: float, b: float) -> float: + return a * b + + def divide(self, a: float, b: float) -> float: + if b == 0: + raise ValueError("Cannot divide by zero") + return a / b + + +def test_addition(): + """Test addition.""" + calc = Calculator() + assert calc.add(2, 3) == 5 + assert calc.add(-1, 1) == 0 + assert calc.add(0, 0) == 0 + + +def test_subtraction(): + """Test subtraction.""" + calc = Calculator() + assert calc.subtract(5, 3) == 2 + assert calc.subtract(0, 5) == -5 + + +def test_multiplication(): + """Test multiplication.""" + calc = Calculator() + assert calc.multiply(3, 4) == 12 + assert calc.multiply(0, 5) == 0 + + +def test_division(): + """Test division.""" + calc = Calculator() + assert calc.divide(6, 3) == 2 + assert calc.divide(5, 2) == 2.5 + + +def test_division_by_zero(): + """Test division by zero raises error.""" + calc = Calculator() + with pytest.raises(ValueError, match="Cannot divide by zero"): + calc.divide(5, 0) +``` + +### Pattern 2: Fixtures for Setup and Teardown + +```python +# test_database.py +import pytest +from typing import Generator + +class Database: + """Simple database class.""" + + def __init__(self, connection_string: str): + self.connection_string = connection_string + self.connected = False + + def connect(self): + """Connect to database.""" + self.connected = True + + def disconnect(self): + """Disconnect from database.""" + self.connected = False + + def query(self, sql: str) -> list: + """Execute query.""" + if not self.connected: + raise RuntimeError("Not connected") + return [{"id": 1, "name": "Test"}] + + +@pytest.fixture +def db() -> Generator[Database, None, None]: + """Fixture that provides connected database.""" + # Setup + database = Database("sqlite:///:memory:") + database.connect() + + # Provide to test + yield database + + # Teardown + database.disconnect() + + +def test_database_query(db): + """Test database query with fixture.""" + results = db.query("SELECT * FROM users") + assert len(results) == 1 + assert results[0]["name"] == "Test" + + +@pytest.fixture(scope="session") +def app_config(): + """Session-scoped fixture - created once per test session.""" + return { + "database_url": "postgresql://localhost/test", + "api_key": "test-key", + "debug": True + } + + +@pytest.fixture(scope="module") +def api_client(app_config): + """Module-scoped fixture - created once per test module.""" + # Setup expensive resource + client = {"config": app_config, "session": "active"} + yield client + # Cleanup + client["session"] = "closed" + + +def test_api_client(api_client): + """Test using api client fixture.""" + assert api_client["session"] == "active" + assert api_client["config"]["debug"] is True +``` + +### Pattern 3: Parameterized Tests + +```python +# test_validation.py +import pytest + +def is_valid_email(email: str) -> bool: + """Check if email is valid.""" + return "@" in email and "." in email.split("@")[1] + + +@pytest.mark.parametrize("email,expected", [ + ("user@example.com", True), + ("test.user@domain.co.uk", True), + ("invalid.email", False), + ("@example.com", False), + ("user@domain", False), + ("", False), +]) +def test_email_validation(email, expected): + """Test email validation with various inputs.""" + assert is_valid_email(email) == expected + + +@pytest.mark.parametrize("a,b,expected", [ + (2, 3, 5), + (0, 0, 0), + (-1, 1, 0), + (100, 200, 300), + (-5, -5, -10), +]) +def test_addition_parameterized(a, b, expected): + """Test addition with multiple parameter sets.""" + from test_calculator import Calculator + calc = Calculator() + assert calc.add(a, b) == expected + + +# Using pytest.param for special cases +@pytest.mark.parametrize("value,expected", [ + pytest.param(1, True, id="positive"), + pytest.param(0, False, id="zero"), + pytest.param(-1, False, id="negative"), +]) +def test_is_positive(value, expected): + """Test with custom test IDs.""" + assert (value > 0) == expected +``` + +### Pattern 4: Mocking with unittest.mock + +```python +# test_api_client.py +import pytest +from unittest.mock import Mock, patch, MagicMock +import requests + +class APIClient: + """Simple API client.""" + + def __init__(self, base_url: str): + self.base_url = base_url + + def get_user(self, user_id: int) -> dict: + """Fetch user from API.""" + response = requests.get(f"{self.base_url}/users/{user_id}") + response.raise_for_status() + return response.json() + + def create_user(self, data: dict) -> dict: + """Create new user.""" + response = requests.post(f"{self.base_url}/users", json=data) + response.raise_for_status() + return response.json() + + +def test_get_user_success(): + """Test successful API call with mock.""" + client = APIClient("https://api.example.com") + + mock_response = Mock() + mock_response.json.return_value = {"id": 1, "name": "John Doe"} + mock_response.raise_for_status.return_value = None + + with patch("requests.get", return_value=mock_response) as mock_get: + user = client.get_user(1) + + assert user["id"] == 1 + assert user["name"] == "John Doe" + mock_get.assert_called_once_with("https://api.example.com/users/1") + + +def test_get_user_not_found(): + """Test API call with 404 error.""" + client = APIClient("https://api.example.com") + + mock_response = Mock() + mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found") + + with patch("requests.get", return_value=mock_response): + with pytest.raises(requests.HTTPError): + client.get_user(999) + + +@patch("requests.post") +def test_create_user(mock_post): + """Test user creation with decorator syntax.""" + client = APIClient("https://api.example.com") + + mock_post.return_value.json.return_value = {"id": 2, "name": "Jane Doe"} + mock_post.return_value.raise_for_status.return_value = None + + user_data = {"name": "Jane Doe", "email": "jane@example.com"} + result = client.create_user(user_data) + + assert result["id"] == 2 + mock_post.assert_called_once() + call_args = mock_post.call_args + assert call_args.kwargs["json"] == user_data +``` + +### Pattern 5: Testing Exceptions + +```python +# test_exceptions.py +import pytest + +def divide(a: float, b: float) -> float: + """Divide a by b.""" + if b == 0: + raise ZeroDivisionError("Division by zero") + if not isinstance(a, (int, float)) or not isinstance(b, (int, float)): + raise TypeError("Arguments must be numbers") + return a / b + + +def test_zero_division(): + """Test exception is raised for division by zero.""" + with pytest.raises(ZeroDivisionError): + divide(10, 0) + + +def test_zero_division_with_message(): + """Test exception message.""" + with pytest.raises(ZeroDivisionError, match="Division by zero"): + divide(5, 0) + + +def test_type_error(): + """Test type error exception.""" + with pytest.raises(TypeError, match="must be numbers"): + divide("10", 5) + + +def test_exception_info(): + """Test accessing exception info.""" + with pytest.raises(ValueError) as exc_info: + int("not a number") + + assert "invalid literal" in str(exc_info.value) +``` + +## Advanced Patterns + +### Pattern 6: Testing Async Code + +```python +# test_async.py +import pytest +import asyncio + +async def fetch_data(url: str) -> dict: + """Fetch data asynchronously.""" + await asyncio.sleep(0.1) + return {"url": url, "data": "result"} + + +@pytest.mark.asyncio +async def test_fetch_data(): + """Test async function.""" + result = await fetch_data("https://api.example.com") + assert result["url"] == "https://api.example.com" + assert "data" in result + + +@pytest.mark.asyncio +async def test_concurrent_fetches(): + """Test concurrent async operations.""" + urls = ["url1", "url2", "url3"] + tasks = [fetch_data(url) for url in urls] + results = await asyncio.gather(*tasks) + + assert len(results) == 3 + assert all("data" in r for r in results) + + +@pytest.fixture +async def async_client(): + """Async fixture.""" + client = {"connected": True} + yield client + client["connected"] = False + + +@pytest.mark.asyncio +async def test_with_async_fixture(async_client): + """Test using async fixture.""" + assert async_client["connected"] is True +``` + +### Pattern 7: Monkeypatch for Testing + +```python +# test_environment.py +import os +import pytest + +def get_database_url() -> str: + """Get database URL from environment.""" + return os.environ.get("DATABASE_URL", "sqlite:///:memory:") + + +def test_database_url_default(): + """Test default database URL.""" + # Will use actual environment variable if set + url = get_database_url() + assert url + + +def test_database_url_custom(monkeypatch): + """Test custom database URL with monkeypatch.""" + monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/test") + assert get_database_url() == "postgresql://localhost/test" + + +def test_database_url_not_set(monkeypatch): + """Test when env var is not set.""" + monkeypatch.delenv("DATABASE_URL", raising=False) + assert get_database_url() == "sqlite:///:memory:" + + +class Config: + """Configuration class.""" + + def __init__(self): + self.api_key = "production-key" + + def get_api_key(self): + return self.api_key + + +def test_monkeypatch_attribute(monkeypatch): + """Test monkeypatching object attributes.""" + config = Config() + monkeypatch.setattr(config, "api_key", "test-key") + assert config.get_api_key() == "test-key" +``` + +### Pattern 8: Temporary Files and Directories + +```python +# test_file_operations.py +import pytest +from pathlib import Path + +def save_data(filepath: Path, data: str): + """Save data to file.""" + filepath.write_text(data) + + +def load_data(filepath: Path) -> str: + """Load data from file.""" + return filepath.read_text() + + +def test_file_operations(tmp_path): + """Test file operations with temporary directory.""" + # tmp_path is a pathlib.Path object + test_file = tmp_path / "test_data.txt" + + # Save data + save_data(test_file, "Hello, World!") + + # Verify file exists + assert test_file.exists() + + # Load and verify data + data = load_data(test_file) + assert data == "Hello, World!" + + +def test_multiple_files(tmp_path): + """Test with multiple temporary files.""" + files = { + "file1.txt": "Content 1", + "file2.txt": "Content 2", + "file3.txt": "Content 3" + } + + for filename, content in files.items(): + filepath = tmp_path / filename + save_data(filepath, content) + + # Verify all files created + assert len(list(tmp_path.iterdir())) == 3 + + # Verify contents + for filename, expected_content in files.items(): + filepath = tmp_path / filename + assert load_data(filepath) == expected_content +``` + +### Pattern 9: Custom Fixtures and Conftest + +```python +# conftest.py +"""Shared fixtures for all tests.""" +import pytest + +@pytest.fixture(scope="session") +def database_url(): + """Provide database URL for all tests.""" + return "postgresql://localhost/test_db" + + +@pytest.fixture(autouse=True) +def reset_database(database_url): + """Auto-use fixture that runs before each test.""" + # Setup: Clear database + print(f"Clearing database: {database_url}") + yield + # Teardown: Clean up + print("Test completed") + + +@pytest.fixture +def sample_user(): + """Provide sample user data.""" + return { + "id": 1, + "name": "Test User", + "email": "test@example.com" + } + + +@pytest.fixture +def sample_users(): + """Provide list of sample users.""" + return [ + {"id": 1, "name": "User 1"}, + {"id": 2, "name": "User 2"}, + {"id": 3, "name": "User 3"}, + ] + + +# Parametrized fixture +@pytest.fixture(params=["sqlite", "postgresql", "mysql"]) +def db_backend(request): + """Fixture that runs tests with different database backends.""" + return request.param + + +def test_with_db_backend(db_backend): + """This test will run 3 times with different backends.""" + print(f"Testing with {db_backend}") + assert db_backend in ["sqlite", "postgresql", "mysql"] +``` + +### Pattern 10: Property-Based Testing + +```python +# test_properties.py +from hypothesis import given, strategies as st +import pytest + +def reverse_string(s: str) -> str: + """Reverse a string.""" + return s[::-1] + + +@given(st.text()) +def test_reverse_twice_is_original(s): + """Property: reversing twice returns original.""" + assert reverse_string(reverse_string(s)) == s + + +@given(st.text()) +def test_reverse_length(s): + """Property: reversed string has same length.""" + assert len(reverse_string(s)) == len(s) + + +@given(st.integers(), st.integers()) +def test_addition_commutative(a, b): + """Property: addition is commutative.""" + assert a + b == b + a + + +@given(st.lists(st.integers())) +def test_sorted_list_properties(lst): + """Property: sorted list is ordered.""" + sorted_lst = sorted(lst) + + # Same length + assert len(sorted_lst) == len(lst) + + # All elements present + assert set(sorted_lst) == set(lst) + + # Is ordered + for i in range(len(sorted_lst) - 1): + assert sorted_lst[i] <= sorted_lst[i + 1] +``` + +## Test Design Principles + +### One Behavior Per Test + +Each test should verify exactly one behavior. This makes failures easy to diagnose and tests easy to maintain. + +```python +# BAD - testing multiple behaviors +def test_user_service(): + user = service.create_user(data) + assert user.id is not None + assert user.email == data["email"] + updated = service.update_user(user.id, {"name": "New"}) + assert updated.name == "New" + +# GOOD - focused tests +def test_create_user_assigns_id(): + user = service.create_user(data) + assert user.id is not None + +def test_create_user_stores_email(): + user = service.create_user(data) + assert user.email == data["email"] + +def test_update_user_changes_name(): + user = service.create_user(data) + updated = service.update_user(user.id, {"name": "New"}) + assert updated.name == "New" +``` + +### Test Error Paths + +Always test failure cases, not just happy paths. + +```python +def test_get_user_raises_not_found(): + with pytest.raises(UserNotFoundError) as exc_info: + service.get_user("nonexistent-id") + + assert "nonexistent-id" in str(exc_info.value) + +def test_create_user_rejects_invalid_email(): + with pytest.raises(ValueError, match="Invalid email format"): + service.create_user({"email": "not-an-email"}) +``` + +## Testing Best Practices + +### Test Organization + +```python +# tests/ +# __init__.py +# conftest.py # Shared fixtures +# test_unit/ # Unit tests +# test_models.py +# test_utils.py +# test_integration/ # Integration tests +# test_api.py +# test_database.py +# test_e2e/ # End-to-end tests +# test_workflows.py +``` + +### Test Naming Convention + +A common pattern: `test___`. Adapt to your team's preferences. + +```python +# Pattern: test___ +def test_create_user_with_valid_data_returns_user(): + ... + +def test_create_user_with_duplicate_email_raises_conflict(): + ... + +def test_get_user_with_unknown_id_returns_none(): + ... + +# Good test names - clear and descriptive +def test_user_creation_with_valid_data(): + """Clear name describes what is being tested.""" + pass + +def test_login_fails_with_invalid_password(): + """Name describes expected behavior.""" + pass + +def test_api_returns_404_for_missing_resource(): + """Specific about inputs and expected outcomes.""" + pass + +# Bad test names - avoid these +def test_1(): # Not descriptive + pass + +def test_user(): # Too vague + pass + +def test_function(): # Doesn't explain what's tested + pass +``` + +### Testing Retry Behavior + +Verify that retry logic works correctly using mock side effects. + +```python +from unittest.mock import Mock + +def test_retries_on_transient_error(): + """Test that service retries on transient failures.""" + client = Mock() + # Fail twice, then succeed + client.request.side_effect = [ + ConnectionError("Failed"), + ConnectionError("Failed"), + {"status": "ok"}, + ] + + service = ServiceWithRetry(client, max_retries=3) + result = service.fetch() + + assert result == {"status": "ok"} + assert client.request.call_count == 3 + +def test_gives_up_after_max_retries(): + """Test that service stops retrying after max attempts.""" + client = Mock() + client.request.side_effect = ConnectionError("Failed") + + service = ServiceWithRetry(client, max_retries=3) + + with pytest.raises(ConnectionError): + service.fetch() + + assert client.request.call_count == 3 + +def test_does_not_retry_on_permanent_error(): + """Test that permanent errors are not retried.""" + client = Mock() + client.request.side_effect = ValueError("Invalid input") + + service = ServiceWithRetry(client, max_retries=3) + + with pytest.raises(ValueError): + service.fetch() + + # Only called once - no retry for ValueError + assert client.request.call_count == 1 +``` + +### Mocking Time with Freezegun + +Use freezegun to control time in tests for predictable time-dependent behavior. + +```python +from freezegun import freeze_time +from datetime import datetime, timedelta + +@freeze_time("2026-01-15 10:00:00") +def test_token_expiry(): + """Test token expires at correct time.""" + token = create_token(expires_in_seconds=3600) + assert token.expires_at == datetime(2026, 1, 15, 11, 0, 0) + +@freeze_time("2026-01-15 10:00:00") +def test_is_expired_returns_false_before_expiry(): + """Test token is not expired when within validity period.""" + token = create_token(expires_in_seconds=3600) + assert not token.is_expired() + +@freeze_time("2026-01-15 12:00:00") +def test_is_expired_returns_true_after_expiry(): + """Test token is expired after validity period.""" + token = Token(expires_at=datetime(2026, 1, 15, 11, 30, 0)) + assert token.is_expired() + +def test_with_time_travel(): + """Test behavior across time using freeze_time context.""" + with freeze_time("2026-01-01") as frozen_time: + item = create_item() + assert item.created_at == datetime(2026, 1, 1) + + # Move forward in time + frozen_time.move_to("2026-01-15") + assert item.age_days == 14 +``` + +### Test Markers + +```python +# test_markers.py +import pytest + +@pytest.mark.slow +def test_slow_operation(): + """Mark slow tests.""" + import time + time.sleep(2) + + +@pytest.mark.integration +def test_database_integration(): + """Mark integration tests.""" + pass + + +@pytest.mark.skip(reason="Feature not implemented yet") +def test_future_feature(): + """Skip tests temporarily.""" + pass + + +@pytest.mark.skipif(os.name == "nt", reason="Unix only test") +def test_unix_specific(): + """Conditional skip.""" + pass + + +@pytest.mark.xfail(reason="Known bug #123") +def test_known_bug(): + """Mark expected failures.""" + assert False + + +# Run with: +# pytest -m slow # Run only slow tests +# pytest -m "not slow" # Skip slow tests +# pytest -m integration # Run integration tests +``` + +### Coverage Reporting + +```bash +# Install coverage +pip install pytest-cov + +# Run tests with coverage +pytest --cov=myapp tests/ + +# Generate HTML report +pytest --cov=myapp --cov-report=html tests/ + +# Fail if coverage below threshold +pytest --cov=myapp --cov-fail-under=80 tests/ + +# Show missing lines +pytest --cov=myapp --cov-report=term-missing tests/ +``` + +## Testing Database Code + +```python +# test_database_models.py +import pytest +from sqlalchemy import create_engine, Column, Integer, String +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, Session + +Base = declarative_base() + + +class User(Base): + """User model.""" + __tablename__ = "users" + + id = Column(Integer, primary_key=True) + name = Column(String(50)) + email = Column(String(100), unique=True) + + +@pytest.fixture(scope="function") +def db_session() -> Session: + """Create in-memory database for testing.""" + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + + SessionLocal = sessionmaker(bind=engine) + session = SessionLocal() + + yield session + + session.close() + + +def test_create_user(db_session): + """Test creating a user.""" + user = User(name="Test User", email="test@example.com") + db_session.add(user) + db_session.commit() + + assert user.id is not None + assert user.name == "Test User" + + +def test_query_user(db_session): + """Test querying users.""" + user1 = User(name="User 1", email="user1@example.com") + user2 = User(name="User 2", email="user2@example.com") + + db_session.add_all([user1, user2]) + db_session.commit() + + users = db_session.query(User).all() + assert len(users) == 2 + + +def test_unique_email_constraint(db_session): + """Test unique email constraint.""" + from sqlalchemy.exc import IntegrityError + + user1 = User(name="User 1", email="same@example.com") + user2 = User(name="User 2", email="same@example.com") + + db_session.add(user1) + db_session.commit() + + db_session.add(user2) + + with pytest.raises(IntegrityError): + db_session.commit() +``` + +## CI/CD Integration + +```yaml +# .github/workflows/test.yml +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install -e ".[dev]" + pip install pytest pytest-cov + + - name: Run tests + run: | + pytest --cov=myapp --cov-report=xml + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml +``` + +## Configuration Files + +```ini +# pytest.ini +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --strict-markers + --tb=short + --cov=myapp + --cov-report=term-missing +markers = + slow: marks tests as slow + integration: marks integration tests + unit: marks unit tests + e2e: marks end-to-end tests +``` + +```toml +# pyproject.toml +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +addopts = [ + "-v", + "--cov=myapp", + "--cov-report=term-missing", +] + +[tool.coverage.run] +source = ["myapp"] +omit = ["*/tests/*", "*/migrations/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", +] +``` + +## Resources + +- **pytest documentation**: https://docs.pytest.org/ +- **unittest.mock**: https://docs.python.org/3/library/unittest.mock.html +- **hypothesis**: Property-based testing +- **pytest-asyncio**: Testing async code +- **pytest-cov**: Coverage reporting +- **pytest-mock**: pytest wrapper for mock + +## Best Practices Summary + +1. **Write tests first** (TDD) or alongside code +2. **One assertion per test** when possible +3. **Use descriptive test names** that explain behavior +4. **Keep tests independent** and isolated +5. **Use fixtures** for setup and teardown +6. **Mock external dependencies** appropriately +7. **Parametrize tests** to reduce duplication +8. **Test edge cases** and error conditions +9. **Measure coverage** but focus on quality +10. **Run tests in CI/CD** on every commit diff --git a/.kiro/steering/code-organization.md b/.kiro/steering/code-organization.md index 23358f0..532be37 100644 --- a/.kiro/steering/code-organization.md +++ b/.kiro/steering/code-organization.md @@ -171,9 +171,9 @@ def save_feature_config(feature_config: Dict): - `cli_tool/ssm/` - Reference implementation with commands/, core/, utils/ - `cli_tool/dynamodb/` - Well organized with commands/, core/, utils/ - `cli_tool/code_reviewer/` - Good separation with prompt/, tools/ +- `cli_tool/aws_login/` - Reorganized with commands/, core/ ### ⚠️ Needs Refactoring -- `cli_tool/aws_login/` - Missing commands/ subdirectory - `cli_tool/commands/upgrade.py` - Should be `cli_tool/upgrade/` with commands/, core/ - `cli_tool/commands/completion.py` - Should be `cli_tool/completion/` with commands/, core/ - `cli_tool/commands/codeartifact_login.py` - Should be `cli_tool/codeartifact/` with commands/, core/ @@ -185,7 +185,7 @@ def save_feature_config(feature_config: Dict): ### Phase 1: Standardize Existing Features (Priority Order) 1. ✅ **SSM** - COMPLETED -2. **AWS Login** - Move files → `cli_tool/aws_login/commands/` and `cli_tool/aws_login/core/` +2. ✅ **AWS Login** - COMPLETED 3. **Upgrade** - Convert `cli_tool/commands/upgrade.py` → `cli_tool/upgrade/` 4. **Completion** - Convert `cli_tool/commands/completion.py` → `cli_tool/completion/` 5. **CodeArtifact** - Convert `cli_tool/commands/codeartifact_login.py` → `cli_tool/codeartifact/` diff --git a/cli_tool/aws_login/README.md b/cli_tool/aws_login/README.md index 269accd..1c0b8e1 100644 --- a/cli_tool/aws_login/README.md +++ b/cli_tool/aws_login/README.md @@ -1,91 +1,120 @@ # AWS Login Module -Modular implementation of the AWS SSO login command. +Modular implementation of the AWS SSO login command with subcommands. ## Module Structure ``` cli_tool/aws_login/ ├── __init__.py # Module exports -├── command.py # Main Click command entry point -├── config.py # AWS config file management -├── credentials.py # Credential expiration checking -├── setup.py # Interactive SSO profile configuration -├── status.py # Status display for all profiles -├── refresh.py # Refresh expired/expiring credentials -├── list.py # List available profiles -└── login.py # SSO login flow +├── command.py # Main Click group entry point +├── commands/ # Command implementations +│ ├── __init__.py # Command exports +│ ├── list.py # List available profiles +│ ├── login.py # SSO login flow +│ ├── setup.py # Interactive SSO profile configuration +│ ├── status.py # Status display for all profiles +│ ├── refresh.py # Refresh expired/expiring credentials +│ └── set_default.py # Set default profile +├── core/ # Core business logic +│ ├── __init__.py # Core exports +│ ├── config.py # AWS config file management +│ └── credentials.py # Credential expiration checking +└── README.md # This file +``` + +## Commands + +- `aws-login` - Login to AWS using SSO with interactive profile selection (default action) +- `login [PROFILE]` - Login to AWS using SSO with specific profile +- `list` - List all AWS profiles with detailed status (expiration, time remaining) +- `configure [PROFILE]` - Configure a new SSO profile interactively +- `refresh` - Refresh expired or expiring credentials +- `set-default [PROFILE]` - Set a profile as the default + +## Usage Examples + +```bash +# Quick interactive login (no subcommand needed) +devo aws-login + +# Login to specific profile +devo aws-login login production +devo aws-login login # also interactive + +# List all profiles with detailed status +devo aws-login list + +# Configure new profile +devo aws-login configure +devo aws-login configure my-profile + +# Refresh expired profiles +devo aws-login refresh + +# Set default profile +devo aws-login set-default production +devo aws-login set-default # interactive selection ``` ## Module Responsibilities ### command.py -- Main Click command definition -- Command-line option parsing -- Routes to appropriate submodules based on flags +- Main Click group definition +- Registers all subcommands +- Registers shortcuts +- AWS CLI availability check -### config.py -- Read/parse AWS config file (~/.aws/config) -- Extract profile configurations -- Handle both legacy SSO format and new sso-session format -- List available profiles and SSO sessions +### commands/login.py +- Perform SSO login flow +- Open browser for authentication +- Cache credentials +- Display expiration information -### credentials.py -- Check credential expiration using AWS CLI -- Verify credentials with STS GetCallerIdentity -- Determine if profiles need refresh -- Get SSO token information from cache +### commands/list.py +- List all available AWS profiles +- Show active/expired status for each profile -### setup.py +### commands/configure.py (setup.py) - Interactive SSO profile configuration - Reuse existing SSO sessions - List available accounts and roles from AWS SSO API - Write profile configuration to AWS config file -### status.py +### commands/status.py - Display detailed expiration status for all profiles - Show expiration time in local timezone - Calculate time remaining for each profile -### refresh.py +### commands/refresh.py - Refresh all expired/expiring profiles - Group profiles by SSO session to minimize logins - Show summary of refresh operations -### list.py -- List all available AWS profiles -- Show active/expired status for each profile - -### login.py -- Perform SSO login flow -- Open browser for authentication -- Cache credentials -- Display expiration information - -## Usage Examples - -```bash -# Login to a profile -devo aws-login --profile production - -# List all profiles -devo aws-login --list +### commands/set_default.py +- Set default AWS profile +- Update shell configuration files +- Handle Windows/Linux/macOS differences -# Show detailed status -devo aws-login --status - -# Refresh expired profiles -devo aws-login --refresh-all +### core/config.py +- Read/parse AWS config file (~/.aws/config) +- Extract profile configurations +- Handle both legacy SSO format and new sso-session format +- List available profiles and SSO sessions -# Configure new profile -devo aws-login --configure -``` +### core/credentials.py +- Check credential expiration using AWS CLI +- Verify credentials with STS GetCallerIdentity +- Determine if profiles need refresh +- Get SSO token information from cache ## Key Features +- Subcommand-based interface (consistent with SSM, DynamoDB) - Detects both legacy SSO format and new sso-session format - Groups profiles by SSO session to minimize login prompts - Shows real expiration time in local timezone - Auto-refresh for expired/expiring credentials (10-minute threshold) - Uses AWS CLI's native credential resolution - Reads account credential expiration (not SSO token expiration) + diff --git a/cli_tool/aws_login/__init__.py b/cli_tool/aws_login/__init__.py index da8e267..3fc5703 100644 --- a/cli_tool/aws_login/__init__.py +++ b/cli_tool/aws_login/__init__.py @@ -1,27 +1,25 @@ """AWS SSO Login module.""" from cli_tool.aws_login.command import aws_login -from cli_tool.aws_login.config import ( +from cli_tool.aws_login.commands import ( + configure_sso_profile, + list_profiles, + perform_login, + refresh_all_profiles, + set_default_profile, +) +from cli_tool.aws_login.core import ( + check_profile_needs_refresh, get_aws_config_path, get_aws_credentials_path, get_existing_sso_sessions, get_profile_config, - list_aws_profiles, - parse_sso_config, -) -from cli_tool.aws_login.credentials import ( - check_profile_needs_refresh, get_profile_credentials_expiration, get_sso_cache_token, - get_sso_token_expiration, + list_aws_profiles, + parse_sso_config, verify_credentials, ) -from cli_tool.aws_login.list import list_profiles -from cli_tool.aws_login.login import perform_login -from cli_tool.aws_login.refresh import refresh_all_profiles -from cli_tool.aws_login.set_default import set_default_profile -from cli_tool.aws_login.setup import configure_sso_profile -from cli_tool.aws_login.status import show_status __all__ = [ "aws_login", @@ -34,12 +32,10 @@ "check_profile_needs_refresh", "get_profile_credentials_expiration", "get_sso_cache_token", - "get_sso_token_expiration", "verify_credentials", "list_profiles", "perform_login", "refresh_all_profiles", "set_default_profile", "configure_sso_profile", - "show_status", ] diff --git a/cli_tool/aws_login/command.py b/cli_tool/aws_login/command.py index b88a477..a16e3b7 100644 --- a/cli_tool/aws_login/command.py +++ b/cli_tool/aws_login/command.py @@ -5,109 +5,118 @@ import click from rich.console import Console -from cli_tool.aws_login.list import list_profiles -from cli_tool.aws_login.login import perform_login -from cli_tool.aws_login.refresh import refresh_all_profiles -from cli_tool.aws_login.set_default import set_default_profile -from cli_tool.aws_login.setup import configure_sso_profile -from cli_tool.aws_login.status import show_status +from cli_tool.aws_login.commands import ( + configure_sso_profile, + list_profiles, + perform_login, + refresh_all_profiles, + set_default_profile, +) from cli_tool.utils.aws import check_aws_cli console = Console() -@click.command() -@click.option( - "--profile", - "-p", - help="AWS profile name to login", -) -@click.option( - "--list", - "-l", - "list_profiles_flag", - is_flag=True, - help="List available AWS profiles", -) -@click.option( - "--configure", - "-c", - is_flag=True, - help="Configure a new SSO profile interactively", -) -@click.option( - "--refresh-all", - "-r", - is_flag=True, - help="Refresh all profiles that are expired or expiring soon (within 10 minutes)", -) -@click.option( - "--status", - "-s", - is_flag=True, - help="Show detailed expiration status for all profiles", -) -@click.option( - "--set-default", - "-d", - is_flag=True, - help="Set a profile as the default (updates shell configuration)", -) -def aws_login(profile, list_profiles_flag, configure, refresh_all, status, set_default): - """Login to AWS using SSO and cache credentials. +@click.group(invoke_without_command=True) +@click.pass_context +def aws_login(ctx): + """AWS SSO authentication and profile management. - Automates the AWS SSO login process by: - - Opening browser for SSO authentication - - Caching credentials automatically - - Showing credential expiration time + Login to AWS using SSO (default action): + devo aws-login # interactive profile selection - Examples: - devo aws-login --profile production - devo aws-login -p dev - devo aws-login --list - devo aws-login --configure - devo aws-login --configure --profile my-profile - devo aws-login --refresh-all - devo aws-login --status - devo aws-login --set-default --profile production + Login to specific profile: + devo aws-login login production + + Other commands: + devo aws-login list # list all profiles with status + devo aws-login configure # configure new profile + devo aws-login refresh # refresh expired credentials + devo aws-login set-default # set default profile """ # Check if AWS CLI is installed if not check_aws_cli(): sys.exit(1) - # Set default profile - if set_default: - set_default_profile(profile) - sys.exit(0) - - # Show detailed status - if status: - show_status() - sys.exit(0) - - # Refresh all profiles that need it - if refresh_all: - refresh_all_profiles() - sys.exit(0) - - # Configure new profile - if configure: - profile_name = configure_sso_profile(profile) - if not profile_name: - sys.exit(1) - - console.print("[blue]Profile configured! Now logging in...[/blue]\n") - profile = profile_name - # Continue to login flow - - # List profiles - if list_profiles_flag: - list_profiles() - sys.exit(0) - - # Login flow + # If a subcommand was invoked, let it handle the request + if ctx.invoked_subcommand is not None: + return + + # Otherwise, perform interactive login + perform_login(None) + + +@aws_login.command("login") +@click.argument("profile", required=False) +def login_cmd(profile): + """Login to AWS using SSO with a specific profile. + + Opens browser for SSO authentication and caches credentials. + + Examples: + devo aws-login login production + devo aws-login login # interactive selection + """ perform_login(profile) +@aws_login.command("list") +def list_cmd(): + """List all AWS profiles with detailed status. + + Shows profiles from both ~/.aws/config and ~/.aws/credentials + with their source (SSO, static, or both), credential status, + expiration time, and time remaining. + """ + list_profiles() + + +@aws_login.command("configure") +@click.argument("profile", required=False) +def configure_cmd(profile): + """Configure a new SSO profile interactively. + + Guides you through setting up a new AWS SSO profile with + account selection and role assignment. + + Examples: + devo aws-login configure + devo aws-login configure my-profile + """ + profile_name = configure_sso_profile(profile) + if not profile_name: + sys.exit(1) + + console.print("\n[blue]Profile configured! Now logging in...[/blue]\n") + perform_login(profile_name) + + +@aws_login.command("refresh") +def refresh_cmd(): + """Refresh expired or expiring credentials. + + Checks all profiles and refreshes those that are expired + or expiring within 10 minutes. Groups profiles by SSO + session to minimize login prompts. + """ + refresh_all_profiles() + + +@aws_login.command("set-default") +@click.argument("profile", required=False) +def set_default_cmd(profile): + """Set a profile as the default. + + Updates shell configuration to export AWS_PROFILE environment + variable. On Linux/macOS, updates ~/.bashrc or ~/.zshrc. + On Windows, sets user environment variable. + + Examples: + devo aws-login set-default production + devo aws-login set-default # interactive selection + """ + set_default_profile(profile) + + if __name__ == "__main__": aws_login() diff --git a/cli_tool/aws_login/commands/__init__.py b/cli_tool/aws_login/commands/__init__.py new file mode 100644 index 0000000..20ec84a --- /dev/null +++ b/cli_tool/aws_login/commands/__init__.py @@ -0,0 +1,15 @@ +"""AWS Login commands.""" + +from cli_tool.aws_login.commands.list import list_profiles +from cli_tool.aws_login.commands.login import perform_login +from cli_tool.aws_login.commands.refresh import refresh_all_profiles +from cli_tool.aws_login.commands.set_default import set_default_profile +from cli_tool.aws_login.commands.setup import configure_sso_profile + +__all__ = [ + "list_profiles", + "perform_login", + "refresh_all_profiles", + "set_default_profile", + "configure_sso_profile", +] diff --git a/cli_tool/aws_login/status.py b/cli_tool/aws_login/commands/list.py similarity index 81% rename from cli_tool/aws_login/status.py rename to cli_tool/aws_login/commands/list.py index 4e7086a..8d653bc 100644 --- a/cli_tool/aws_login/status.py +++ b/cli_tool/aws_login/commands/list.py @@ -1,4 +1,4 @@ -"""Status display for AWS profiles.""" +"""List AWS profiles with detailed status.""" import sys from datetime import datetime, timezone @@ -6,20 +6,20 @@ from rich.console import Console from rich.table import Table -from cli_tool.aws_login.config import get_profile_config, list_aws_profiles -from cli_tool.aws_login.credentials import get_profile_credentials_expiration +from cli_tool.aws_login.core.config import get_profile_config, list_aws_profiles +from cli_tool.aws_login.core.credentials import get_profile_credentials_expiration console = Console() -def show_status(): - """Show detailed expiration status for all profiles.""" +def list_profiles(): + """List all available AWS profiles with detailed status.""" profiles = list_aws_profiles() if not profiles: console.print("[yellow]No AWS profiles found[/yellow]") sys.exit(0) - console.print("[blue]═══ AWS Profile Expiration Status ═══[/blue]\n") + console.print("[blue]═══ AWS Profiles ═══[/blue]\n") table = Table(show_header=True, header_style="bold cyan") table.add_column("Profile", style="cyan", width=20) @@ -79,5 +79,3 @@ def show_status(): table.add_row(prof, source, status_str, expires_str, time_str) console.print(table) - console.print(f"\n[dim]Current time (UTC): {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')}[/dim]") - console.print(f"\n[dim]Current time (Local): {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}[/dim]") diff --git a/cli_tool/aws_login/login.py b/cli_tool/aws_login/commands/login.py similarity index 94% rename from cli_tool/aws_login/login.py rename to cli_tool/aws_login/commands/login.py index 4a8b8e6..2b8589a 100644 --- a/cli_tool/aws_login/login.py +++ b/cli_tool/aws_login/commands/login.py @@ -7,12 +7,12 @@ import click from rich.console import Console -from cli_tool.aws_login.config import list_aws_profiles, parse_sso_config -from cli_tool.aws_login.credentials import ( +from cli_tool.aws_login.commands.setup import configure_sso_profile +from cli_tool.aws_login.core.config import list_aws_profiles, parse_sso_config +from cli_tool.aws_login.core.credentials import ( get_profile_credentials_expiration, verify_credentials, ) -from cli_tool.aws_login.setup import configure_sso_profile console = Console() @@ -33,7 +33,7 @@ def perform_login(profile_name=None): console.print("\n[blue]Profile configured! Now logging in...[/blue]\n") else: console.print("\nTo configure SSO, run:") - console.print(" devo aws-login --configure") + console.print(" devo aws-login configure") console.print("\nOr manually:") console.print(" aws configure sso") sys.exit(1) @@ -66,7 +66,7 @@ def perform_login(profile_name=None): if not sso_config: console.print(f"[yellow]Profile '{profile_name}' is not configured for SSO[/yellow]") console.print("\nTo configure SSO, run:") - console.print(" devo aws-login --configure") + console.print(" devo aws-login configure") console.print("\nOr manually:") console.print(f" aws configure sso --profile {profile_name}") sys.exit(1) diff --git a/cli_tool/aws_login/refresh.py b/cli_tool/aws_login/commands/refresh.py similarity index 97% rename from cli_tool/aws_login/refresh.py rename to cli_tool/aws_login/commands/refresh.py index 2f4d00a..22701ef 100644 --- a/cli_tool/aws_login/refresh.py +++ b/cli_tool/aws_login/commands/refresh.py @@ -8,8 +8,8 @@ from rich.console import Console from rich.table import Table -from cli_tool.aws_login.config import get_profile_config, list_aws_profiles -from cli_tool.aws_login.credentials import ( +from cli_tool.aws_login.core.config import get_profile_config, list_aws_profiles +from cli_tool.aws_login.core.credentials import ( check_profile_needs_refresh, verify_credentials, ) diff --git a/cli_tool/aws_login/set_default.py b/cli_tool/aws_login/commands/set_default.py similarity index 99% rename from cli_tool/aws_login/set_default.py rename to cli_tool/aws_login/commands/set_default.py index 69e869b..6d34a8f 100644 --- a/cli_tool/aws_login/set_default.py +++ b/cli_tool/aws_login/commands/set_default.py @@ -8,7 +8,7 @@ import click from rich.console import Console -from cli_tool.aws_login.config import list_aws_profiles +from cli_tool.aws_login.core.config import list_aws_profiles console = Console() diff --git a/cli_tool/aws_login/setup.py b/cli_tool/aws_login/commands/setup.py similarity index 99% rename from cli_tool/aws_login/setup.py rename to cli_tool/aws_login/commands/setup.py index 8f861f5..e195fe9 100644 --- a/cli_tool/aws_login/setup.py +++ b/cli_tool/aws_login/commands/setup.py @@ -6,12 +6,12 @@ import click from rich.console import Console -from cli_tool.aws_login.config import ( +from cli_tool.aws_login.core.config import ( get_aws_config_path, get_existing_sso_sessions, get_profile_config, ) -from cli_tool.aws_login.credentials import get_sso_cache_token +from cli_tool.aws_login.core.credentials import get_sso_cache_token console = Console() diff --git a/cli_tool/aws_login/core/__init__.py b/cli_tool/aws_login/core/__init__.py new file mode 100644 index 0000000..866a1fc --- /dev/null +++ b/cli_tool/aws_login/core/__init__.py @@ -0,0 +1,29 @@ +"""AWS Login core functionality.""" + +from cli_tool.aws_login.core.config import ( + get_aws_config_path, + get_aws_credentials_path, + get_existing_sso_sessions, + get_profile_config, + list_aws_profiles, + parse_sso_config, +) +from cli_tool.aws_login.core.credentials import ( + check_profile_needs_refresh, + get_profile_credentials_expiration, + get_sso_cache_token, + verify_credentials, +) + +__all__ = [ + "get_aws_config_path", + "get_aws_credentials_path", + "get_existing_sso_sessions", + "get_profile_config", + "list_aws_profiles", + "parse_sso_config", + "check_profile_needs_refresh", + "get_profile_credentials_expiration", + "get_sso_cache_token", + "verify_credentials", +] diff --git a/cli_tool/aws_login/config.py b/cli_tool/aws_login/core/config.py similarity index 100% rename from cli_tool/aws_login/config.py rename to cli_tool/aws_login/core/config.py diff --git a/cli_tool/aws_login/credentials.py b/cli_tool/aws_login/core/credentials.py similarity index 98% rename from cli_tool/aws_login/credentials.py rename to cli_tool/aws_login/core/credentials.py index 2577c7f..e7b1023 100644 --- a/cli_tool/aws_login/credentials.py +++ b/cli_tool/aws_login/core/credentials.py @@ -7,7 +7,7 @@ from rich.console import Console -from cli_tool.aws_login.config import get_profile_config +from cli_tool.aws_login.core.config import get_profile_config console = Console() diff --git a/cli_tool/aws_login/list.py b/cli_tool/aws_login/list.py deleted file mode 100644 index d9c3ada..0000000 --- a/cli_tool/aws_login/list.py +++ /dev/null @@ -1,35 +0,0 @@ -"""List AWS profiles.""" - -import sys - -from rich.console import Console -from rich.table import Table - -from cli_tool.aws_login.config import list_aws_profiles -from cli_tool.aws_login.credentials import verify_credentials - -console = Console() - - -def list_profiles(): - """List all available AWS profiles with their status.""" - profiles = list_aws_profiles() - if not profiles: - console.print("[yellow]No AWS profiles found in ~/.aws/config[/yellow]") - console.print("\nTo configure SSO, run:") - console.print(" devo aws-login --configure") - console.print("\nOr manually:") - console.print(" aws configure sso") - sys.exit(0) - - table = Table(title="Available AWS Profiles") - table.add_column("Profile", style="cyan") - table.add_column("Source", style="dim") - table.add_column("Status", style="green") - - for prof, source in profiles: - identity = verify_credentials(prof) - status = "✓ Active" if identity else "✗ Expired/Invalid" - table.add_row(prof, source, status) - - console.print(table) diff --git a/cli_tool/commands/upgrade.py b/cli_tool/commands/upgrade.py index 08cab78..f0f9081 100644 --- a/cli_tool/commands/upgrade.py +++ b/cli_tool/commands/upgrade.py @@ -383,8 +383,7 @@ def upgrade(force, check): # If only checking, stop here if check: - click.echo("\n✨ New version available!") - click.echo("Run 'devo upgrade' to update") + click.echo(click.style("\n→ Update available - Run 'devo upgrade' to update", dim=True)) return # Detect platform diff --git a/cli_tool/utils/aws_profile.py b/cli_tool/utils/aws_profile.py index 6ab5f96..05cd1a3 100644 --- a/cli_tool/utils/aws_profile.py +++ b/cli_tool/utils/aws_profile.py @@ -23,7 +23,7 @@ def get_aws_profiles(): - 'both': Profile in both config and credentials - 'config': Profile in config without SSO """ - from cli_tool.aws_login.config import list_aws_profiles + from cli_tool.aws_login.core.config import list_aws_profiles # Get profiles with source information profiles = list_aws_profiles() diff --git a/cli_tool/utils/version_check.py b/cli_tool/utils/version_check.py index e1b7d69..dac1d13 100644 --- a/cli_tool/utils/version_check.py +++ b/cli_tool/utils/version_check.py @@ -148,8 +148,7 @@ def show_update_notification(): if has_update and latest_version: print() - print(f"✨ New version available: v{latest_version} (current: v{current_version})") - print(" Run 'devo upgrade' to update") + print(f"\033[2m→ Update available: v{latest_version} (current: v{current_version}) - Run 'devo upgrade'\033[0m") except Exception: # Silently fail - don't interrupt user's workflow pass From 2bbb19bbc73653689a7971928d90604dddd2a7b8 Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Sun, 1 Mar 2026 23:26:03 -0500 Subject: [PATCH 03/37] refactor(upgrade): restructure into modular command and core architecture - Move upgrade logic from cli_tool/commands/upgrade.py to modular structure - Create cli_tool/upgrade/ with commands/ and core/ subdirectories - Extract version management into core/version.py - Extract platform detection into core/platform.py - Extract download functionality into core/downloader.py - Extract installation logic into core/installer.py - Replace original upgrade.py with lightweight import wrapper - Update code organization documentation to reflect completion - Standardize upgrade module structure to match aws_login and ssm patterns --- .kiro/steering/code-organization.md | 4 +- cli_tool/commands/upgrade.py | 497 +------------------------- cli_tool/upgrade/__init__.py | 5 + cli_tool/upgrade/command.py | 5 + cli_tool/upgrade/commands/__init__.py | 5 + cli_tool/upgrade/commands/upgrade.py | 173 +++++++++ cli_tool/upgrade/core/__init__.py | 17 + cli_tool/upgrade/core/downloader.py | 92 +++++ cli_tool/upgrade/core/installer.py | 165 +++++++++ cli_tool/upgrade/core/platform.py | 65 ++++ cli_tool/upgrade/core/version.py | 27 ++ 11 files changed, 560 insertions(+), 495 deletions(-) create mode 100644 cli_tool/upgrade/__init__.py create mode 100644 cli_tool/upgrade/command.py create mode 100644 cli_tool/upgrade/commands/__init__.py create mode 100644 cli_tool/upgrade/commands/upgrade.py create mode 100644 cli_tool/upgrade/core/__init__.py create mode 100644 cli_tool/upgrade/core/downloader.py create mode 100644 cli_tool/upgrade/core/installer.py create mode 100644 cli_tool/upgrade/core/platform.py create mode 100644 cli_tool/upgrade/core/version.py diff --git a/.kiro/steering/code-organization.md b/.kiro/steering/code-organization.md index 532be37..55a5643 100644 --- a/.kiro/steering/code-organization.md +++ b/.kiro/steering/code-organization.md @@ -172,6 +172,7 @@ def save_feature_config(feature_config: Dict): - `cli_tool/dynamodb/` - Well organized with commands/, core/, utils/ - `cli_tool/code_reviewer/` - Good separation with prompt/, tools/ - `cli_tool/aws_login/` - Reorganized with commands/, core/ +- `cli_tool/upgrade/` - Reorganized with core/ (single command, no commands/ needed) ### ⚠️ Needs Refactoring - `cli_tool/commands/upgrade.py` - Should be `cli_tool/upgrade/` with commands/, core/ @@ -186,7 +187,8 @@ def save_feature_config(feature_config: Dict): ### Phase 1: Standardize Existing Features (Priority Order) 1. ✅ **SSM** - COMPLETED 2. ✅ **AWS Login** - COMPLETED -3. **Upgrade** - Convert `cli_tool/commands/upgrade.py` → `cli_tool/upgrade/` +3. ✅ **Upgrade** - COMPLETED +4. **Completion** - Convert `cli_tool/commands/completion.py` → `cli_tool/completion/` 4. **Completion** - Convert `cli_tool/commands/completion.py` → `cli_tool/completion/` 5. **CodeArtifact** - Convert `cli_tool/commands/codeartifact_login.py` → `cli_tool/codeartifact/` 6. **Commit** - Convert `cli_tool/commands/commit_prompt.py` → `cli_tool/commit/` diff --git a/cli_tool/commands/upgrade.py b/cli_tool/commands/upgrade.py index f0f9081..07f1741 100644 --- a/cli_tool/commands/upgrade.py +++ b/cli_tool/commands/upgrade.py @@ -1,496 +1,5 @@ -import os -import platform -import shutil -import sys -import tarfile -import tempfile -import zipfile -from pathlib import Path +"""Upgrade command - imports from upgrade module.""" -import click -import requests +from cli_tool.upgrade import upgrade - -def get_current_version(): - """Get current installed version""" - try: - from cli_tool._version import __version__ - - return __version__ - except ImportError: - return "unknown" - - -def get_latest_release(): - """Get latest release info from GitHub""" - try: - from cli_tool.config import GITHUB_API_RELEASES_URL - - response = requests.get(GITHUB_API_RELEASES_URL, timeout=10) - response.raise_for_status() - return response.json() - except Exception as e: - click.echo(f"Error fetching latest release: {str(e)}", err=True) - return None - - -def detect_platform(): - """Detect current platform and architecture""" - system = platform.system().lower() - machine = platform.machine().lower() - - # Map system names - if system == "darwin": - system = "darwin" - elif system == "linux": - system = "linux" - elif system == "windows": - system = "windows" - else: - return None - - # Map architecture names - if machine in ["x86_64", "amd64"]: - arch = "amd64" - elif machine in ["arm64", "aarch64"]: - arch = "arm64" - else: - return None - - return system, arch - - -def get_binary_name(system, arch): - """Get binary name for platform""" - if system == "windows": - return f"devo-{system}-{arch}.zip" - elif system == "darwin": - # macOS uses tarball (onedir mode) - return f"devo-{system}-{arch}.tar.gz" - else: - # Linux uses single binary (onefile mode) - return f"devo-{system}-{arch}" - - -def get_executable_path(): - """Get path of current executable""" - # Check if running as PyInstaller bundle - if getattr(sys, "frozen", False): - exe_path = Path(sys.executable) - system = platform.system().lower() - - # Windows/macOS onedir: return the parent directory - if system in ["windows", "darwin"] and exe_path.name in ["devo.exe", "devo"]: - return exe_path.parent - # Linux onefile: return the executable itself - return exe_path - - # Running as Python script - find devo in PATH - devo_path = shutil.which("devo") - if devo_path: - return Path(devo_path) - - return None - - -def verify_binary(binary_path, is_archive=False, archive_type=None): - """Verify downloaded binary is valid""" - try: - # For archive files (ZIP/tarball), verify format - if is_archive: - if archive_type == "zip": - if not zipfile.is_zipfile(binary_path): - click.echo("Error: Downloaded file is not a valid ZIP archive") - return False - # Check ZIP contains devo directory with devo.exe - with zipfile.ZipFile(binary_path, "r") as zf: - names = [name.replace("\\", "/") for name in zf.namelist()] - if not any("devo.exe" in name for name in names): - click.echo("Error: ZIP does not contain devo.exe") - return False - elif archive_type == "tar.gz": - import tarfile - - if not tarfile.is_tarfile(binary_path): - click.echo("Error: Downloaded file is not a valid tar.gz archive") - return False - # Check tarball contains devo directory with devo executable - with tarfile.open(binary_path, "r:gz") as tf: - names = tf.getnames() - if not any("devo/devo" in name or name.endswith("/devo") for name in names): - click.echo("Error: tar.gz does not contain devo executable") - return False - return True - - # Check file size (should be at least 10MB for PyInstaller binary) - file_size = binary_path.stat().st_size - if file_size < 10 * 1024 * 1024: # 10MB - click.echo(f"Warning: Binary size is only {file_size / 1024 / 1024:.1f}MB, seems too small") - return False - - # Check if file is executable format (basic check) - with open(binary_path, "rb") as f: - magic = f.read(4) - # ELF magic for Linux: 7f 45 4c 46 - # Mach-O magic for macOS: cf fa ed fe or fe ed fa ce (and others) - # PE magic for Windows: 4d 5a (MZ) - if sys.platform.startswith("linux") and magic[:4] != b"\x7fELF": - click.echo("Error: Downloaded file is not a valid Linux ELF binary") - return False - elif sys.platform == "darwin" and magic[:4] not in [ - b"\xcf\xfa\xed\xfe", - b"\xfe\xed\xfa\xce", - b"\xce\xfa\xed\xfe", - b"\xfe\xed\xfa\xcf", - ]: - click.echo("Error: Downloaded file is not a valid macOS binary") - return False - elif sys.platform == "win32" and magic[:2] != b"MZ": - click.echo("Error: Downloaded file is not a valid Windows PE binary") - return False - - return True - except Exception as e: - click.echo(f"Error verifying binary: {str(e)}") - return False - - -def download_binary(url, dest_path): - """Download binary from URL with progress""" - try: - response = requests.get(url, stream=True, timeout=30) - response.raise_for_status() - - total_size = int(response.headers.get("content-length", 0)) - block_size = 8192 - downloaded = 0 - - with open(dest_path, "wb") as f: - with click.progressbar(length=total_size, label="Downloading", show_percent=True, show_pos=True) as bar: - for chunk in response.iter_content(chunk_size=block_size): - if chunk: - f.write(chunk) - downloaded += len(chunk) - bar.update(len(chunk)) - - return True - except Exception as e: - click.echo(f"\nError downloading binary: {str(e)}", err=True) - return False - - -def replace_binary(new_binary_path, target_path, archive_type=None): - """Replace current binary with new one""" - try: - system = platform.system().lower() - - # Handle archive extraction (Windows ZIP or macOS tarball) - if archive_type: - # Create backup of entire directory - backup_path = target_path.parent / f"{target_path.name}.backup" - if backup_path.exists(): - try: - shutil.rmtree(backup_path) - except OSError as e: - click.echo(f"Warning: Could not remove old backup: {e}", err=True) - - shutil.copytree(str(target_path), str(backup_path)) - click.echo(f"Backup created: {backup_path}") - - # Extract archive to temporary location - temp_extract = target_path.parent / "devo_new" - if temp_extract.exists(): - try: - shutil.rmtree(temp_extract) - except OSError as e: - click.echo(f"Warning: Could not remove old temp directory: {e}", err=True) - temp_extract.mkdir() - - try: - # Extract based on archive type - if archive_type == "zip": - with zipfile.ZipFile(new_binary_path, "r") as zf: - zf.extractall(temp_extract) - elif archive_type == "tar.gz": - with tarfile.open(new_binary_path, "r:gz") as tf: - tf.extractall(temp_extract) - - # Find the extracted devo directory - extracted_dir = None - for item in temp_extract.iterdir(): - if item.is_dir() and item.name.startswith("devo"): - extracted_dir = item - break - - if extracted_dir is None: - # Archive contains files at the root - extracted_dir = temp_extract - - if system == "windows": - # Windows: Use PowerShell script for replacement after exit - script_path = target_path.parent / "upgrade_devo.ps1" - script_content = f""" -# Wait for current process to exit -$processId = {os.getpid()} -Write-Host "Waiting for process $processId to exit..." -Wait-Process -Id $processId -ErrorAction SilentlyContinue - -# Give it a moment -Start-Sleep -Seconds 2 - -# Remove old installation -Write-Host "Removing old installation..." -if (Test-Path "{target_path}") {{ - Remove-Item -Path "{target_path}" -Recurse -Force -ErrorAction Stop -}} - -# Move new installation into place -Write-Host "Installing new version..." -Move-Item -Path "{extracted_dir}" -Destination "{target_path}" -Force -ErrorAction Stop - -# Clean up -Write-Host "Cleaning up..." -if (Test-Path "{temp_extract}") {{ - Remove-Item -Path "{temp_extract}" -Recurse -Force -ErrorAction SilentlyContinue -}} -Remove-Item -Path "{script_path}" -Force -ErrorAction SilentlyContinue - -Write-Host "Upgrade complete!" -Write-Host "You can now run: devo --version" -""" - - with open(script_path, "w", encoding="utf-8") as f: - f.write(script_content) - - click.echo("\n✨ Upgrade prepared successfully!") - click.echo("\nThe upgrade will complete after this process exits.") - click.echo("Starting upgrade script...") - - # Start the PowerShell script in a new window - import subprocess - - subprocess.Popen( - ["powershell.exe", "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", str(script_path)], - creationflags=subprocess.CREATE_NEW_CONSOLE, - ) - - return True - else: - # macOS: Direct replacement (can replace while running) - # Remove old directory - shutil.rmtree(target_path) - - # Move new directory into place - shutil.move(str(extracted_dir), str(target_path)) - - # Make executable - exe_file = target_path / "devo" - os.chmod(exe_file, 0o755) - - # Clean up temp directory - if temp_extract.exists(): - try: - shutil.rmtree(temp_extract) - except OSError: - pass - - click.echo(f"\nBackup location: {backup_path}") - click.echo("\nTo restore backup if needed:") - click.echo(f" rm -rf {target_path}") - click.echo(f" mv {backup_path} {target_path}") - - return True - - except Exception as e: - click.echo(f"Error preparing upgrade: {e}", err=True) - # Clean up temp directory - if temp_extract.exists(): - try: - shutil.rmtree(temp_extract) - except OSError: - pass - return False - - # Linux: single binary replacement (onefile mode) - # Make new binary executable - os.chmod(new_binary_path, 0o755) - - # Always create a backup - backup_path = target_path.with_suffix(".backup") - if backup_path.exists(): - backup_path.unlink() - - # Copy current binary to backup - shutil.copy2(str(target_path), str(backup_path)) - click.echo(f"Backup created: {backup_path}") - - # Replace the file directly - shutil.move(str(new_binary_path), str(target_path)) - - click.echo("\nTo restore backup if needed:") - click.echo(f" mv {backup_path} {target_path}") - - return True - except Exception as e: - click.echo(f"Error replacing binary: {str(e)}", err=True) - return False - - -@click.command() -@click.option("--force", "-f", is_flag=True, help="Force upgrade without confirmation") -@click.option("--check", "-c", is_flag=True, help="Check for updates without upgrading") -def upgrade(force, check): - """Upgrade the CLI tool to the latest version from GitHub Releases""" - # Disable version check for upgrade command - os.environ["DEVO_SKIP_VERSION_CHECK"] = "1" - - # Clear cache when checking to force fresh check - if check: - from cli_tool.utils.version_check import clear_cache - - clear_cache() - - try: - click.echo("Checking for updates...") - - # Get current version - current_version = get_current_version() - if current_version == "unknown": - click.echo("Warning: Could not determine current version", err=True) - - # Get latest release - release_info = get_latest_release() - if not release_info: - click.echo("Error: Could not fetch latest release information", err=True) - sys.exit(1) - - latest_version = release_info.get("tag_name", "").lstrip("v") - if not latest_version: - click.echo("Error: Could not determine latest version", err=True) - sys.exit(1) - - # Compare versions - if current_version != "unknown" and current_version == latest_version: - click.echo(f"✨ You already have the latest version ({current_version})") - if not force and not check: - click.echo("Use --force to reinstall anyway") - return - elif check: - return - - click.echo(f"Current version: {current_version}") - click.echo(f"Latest version: {latest_version}") - - # If only checking, stop here - if check: - click.echo(click.style("\n→ Update available - Run 'devo upgrade' to update", dim=True)) - return - - # Detect platform - platform_info = detect_platform() - if not platform_info: - click.echo("Error: Unsupported platform", err=True) - sys.exit(1) - - system, arch = platform_info - binary_name = get_binary_name(system, arch) - - # Determine archive type - archive_type = None - if system == "windows": - archive_type = "zip" - elif system == "darwin": - archive_type = "tar.gz" - # Linux uses single binary (no archive) - - # Find binary in release assets - asset_url = None - for asset in release_info.get("assets", []): - if asset["name"] == binary_name: - asset_url = asset["browser_download_url"] - break - - if not asset_url: - click.echo(f"Error: Binary not found for {system}-{arch}", err=True) - click.echo(f"Looking for: {binary_name}", err=True) - sys.exit(1) - - # Get current executable path - current_exe = get_executable_path() - if not current_exe: - click.echo("Error: Could not determine current executable location", err=True) - click.echo("Please install manually from GitHub Releases", err=True) - sys.exit(1) - - click.echo(f"Binary location: {current_exe}") - - # Check write permissions - check_path = current_exe.parent - if not os.access(check_path, os.W_OK): - click.echo(f"Error: No write permission to {check_path}", err=True) - click.echo("Try running with sudo or install to a user-writable location", err=True) - sys.exit(1) - - if not force: - if not click.confirm("Do you want to continue with the upgrade?"): - click.echo("Upgrade cancelled") - return - - # Download new binary to temporary file - click.echo(f"\nDownloading {binary_name}...") - if archive_type == "zip": - suffix = ".zip" - elif archive_type == "tar.gz": - suffix = ".tar.gz" - else: - suffix = ".tmp" - - with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file: - tmp_path = Path(tmp_file.name) - - try: - if not download_binary(asset_url, tmp_path): - sys.exit(1) - - # Verify downloaded binary - click.echo("\nVerifying downloaded binary...") - if not verify_binary(tmp_path, is_archive=bool(archive_type), archive_type=archive_type): - click.echo("Error: Downloaded binary failed verification", err=True) - click.echo("The file may be corrupted. Please try again.", err=True) - sys.exit(1) - - # Replace binary - click.echo("\nInstalling new version...") - if not replace_binary(tmp_path, current_exe, archive_type=archive_type): - sys.exit(1) - - # Clean up temp file immediately after successful replacement - if tmp_path.exists(): - try: - tmp_path.unlink() - except Exception: - pass # Ignore cleanup errors - - click.echo(f"\n✨ Successfully upgraded to version {latest_version}!") - click.echo("\nVerify the upgrade:") - click.echo(" devo --version") - click.echo("\n💡 Tip: Run 'devo completion --install' to set up shell completion") - - # Use os._exit to terminate immediately without cleanup handlers - # This prevents the old binary process from trying to access the new binary - os._exit(0) - - finally: - # Cleanup for error cases only - if tmp_path.exists(): - try: - tmp_path.unlink() - except Exception: - pass - - except KeyboardInterrupt: - click.echo("\n\nUpgrade cancelled by user") - sys.exit(1) - except Exception as e: - click.echo(f"\nError during upgrade: {str(e)}", err=True) - sys.exit(1) +__all__ = ["upgrade"] diff --git a/cli_tool/upgrade/__init__.py b/cli_tool/upgrade/__init__.py new file mode 100644 index 0000000..e61371d --- /dev/null +++ b/cli_tool/upgrade/__init__.py @@ -0,0 +1,5 @@ +"""Upgrade module.""" + +from cli_tool.upgrade.command import upgrade + +__all__ = ["upgrade"] diff --git a/cli_tool/upgrade/command.py b/cli_tool/upgrade/command.py new file mode 100644 index 0000000..0014b23 --- /dev/null +++ b/cli_tool/upgrade/command.py @@ -0,0 +1,5 @@ +"""Upgrade command entry point.""" + +from cli_tool.upgrade.commands.upgrade import upgrade + +__all__ = ["upgrade"] diff --git a/cli_tool/upgrade/commands/__init__.py b/cli_tool/upgrade/commands/__init__.py new file mode 100644 index 0000000..789cc48 --- /dev/null +++ b/cli_tool/upgrade/commands/__init__.py @@ -0,0 +1,5 @@ +"""Upgrade commands.""" + +from cli_tool.upgrade.commands.upgrade import upgrade + +__all__ = ["upgrade"] diff --git a/cli_tool/upgrade/commands/upgrade.py b/cli_tool/upgrade/commands/upgrade.py new file mode 100644 index 0000000..a830900 --- /dev/null +++ b/cli_tool/upgrade/commands/upgrade.py @@ -0,0 +1,173 @@ +"""Upgrade command implementation.""" + +import os +import sys +import tempfile +from pathlib import Path + +import click + +from cli_tool.upgrade.core.downloader import download_binary, verify_binary +from cli_tool.upgrade.core.installer import replace_binary +from cli_tool.upgrade.core.platform import detect_platform, get_binary_name, get_executable_path +from cli_tool.upgrade.core.version import get_current_version, get_latest_release + + +@click.command() +@click.option("--force", "-f", is_flag=True, help="Force upgrade without confirmation") +@click.option("--check", "-c", is_flag=True, help="Check for updates without upgrading") +def upgrade(force, check): + """Upgrade the CLI tool to the latest version from GitHub Releases""" + # Disable version check for upgrade command + os.environ["DEVO_SKIP_VERSION_CHECK"] = "1" + + # Clear cache when checking to force fresh check + if check: + from cli_tool.utils.version_check import clear_cache + + clear_cache() + + try: + click.echo("Checking for updates...") + + # Get current version + current_version = get_current_version() + if current_version == "unknown": + click.echo("Warning: Could not determine current version", err=True) + + # Get latest release + release_info = get_latest_release() + if not release_info: + click.echo("Error: Could not fetch latest release information", err=True) + sys.exit(1) + + latest_version = release_info.get("tag_name", "").lstrip("v") + if not latest_version: + click.echo("Error: Could not determine latest version", err=True) + sys.exit(1) + + # Compare versions + if current_version != "unknown" and current_version == latest_version: + click.echo(f"✨ You already have the latest version ({current_version})") + if not force and not check: + click.echo("Use --force to reinstall anyway") + return + elif check: + return + + click.echo(f"Current version: {current_version}") + click.echo(f"Latest version: {latest_version}") + + # If only checking, stop here + if check: + click.echo(click.style("\n→ Update available - Run 'devo upgrade' to update", dim=True)) + return + + # Detect platform + platform_info = detect_platform() + if not platform_info: + click.echo("Error: Unsupported platform", err=True) + sys.exit(1) + + system, arch = platform_info + binary_name = get_binary_name(system, arch) + + # Determine archive type + archive_type = None + if system == "windows": + archive_type = "zip" + elif system == "darwin": + archive_type = "tar.gz" + # Linux uses single binary (no archive) + + # Find binary in release assets + asset_url = None + for asset in release_info.get("assets", []): + if asset["name"] == binary_name: + asset_url = asset["browser_download_url"] + break + + if not asset_url: + click.echo(f"Error: Binary not found for {system}-{arch}", err=True) + click.echo(f"Looking for: {binary_name}", err=True) + sys.exit(1) + + # Get current executable path + current_exe = get_executable_path() + if not current_exe: + click.echo("Error: Could not determine current executable location", err=True) + click.echo("Please install manually from GitHub Releases", err=True) + sys.exit(1) + + click.echo(f"Binary location: {current_exe}") + + # Check write permissions + check_path = current_exe.parent + if not os.access(check_path, os.W_OK): + click.echo(f"Error: No write permission to {check_path}", err=True) + click.echo("Try running with sudo or install to a user-writable location", err=True) + sys.exit(1) + + if not force: + if not click.confirm("Do you want to continue with the upgrade?"): + click.echo("Upgrade cancelled") + return + + # Download new binary to temporary file + click.echo(f"\nDownloading {binary_name}...") + if archive_type == "zip": + suffix = ".zip" + elif archive_type == "tar.gz": + suffix = ".tar.gz" + else: + suffix = ".tmp" + + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file: + tmp_path = Path(tmp_file.name) + + try: + if not download_binary(asset_url, tmp_path): + sys.exit(1) + + # Verify downloaded binary + click.echo("\nVerifying downloaded binary...") + if not verify_binary(tmp_path, is_archive=bool(archive_type), archive_type=archive_type): + click.echo("Error: Downloaded binary failed verification", err=True) + click.echo("The file may be corrupted. Please try again.", err=True) + sys.exit(1) + + # Replace binary + click.echo("\nInstalling new version...") + if not replace_binary(tmp_path, current_exe, archive_type=archive_type): + sys.exit(1) + + # Clean up temp file immediately after successful replacement + if tmp_path.exists(): + try: + tmp_path.unlink() + except Exception: + pass # Ignore cleanup errors + + click.echo(f"\n✨ Successfully upgraded to version {latest_version}!") + click.echo("\nVerify the upgrade:") + click.echo(" devo --version") + click.echo("\n💡 Tip: Run 'devo completion --install' to set up shell completion") + + # Use os._exit to terminate immediately without cleanup handlers + # This prevents the old binary process from trying to access the new binary + os._exit(0) + + finally: + # Cleanup for error cases only + if tmp_path.exists(): + try: + tmp_path.unlink() + except Exception: + pass + + except KeyboardInterrupt: + click.echo("\n\nUpgrade cancelled by user") + sys.exit(1) + except Exception as e: + click.echo(f"\nError during upgrade: {str(e)}", err=True) + sys.exit(1) diff --git a/cli_tool/upgrade/core/__init__.py b/cli_tool/upgrade/core/__init__.py new file mode 100644 index 0000000..dff6f5c --- /dev/null +++ b/cli_tool/upgrade/core/__init__.py @@ -0,0 +1,17 @@ +"""Upgrade core functionality.""" + +from cli_tool.upgrade.core.downloader import download_binary, verify_binary +from cli_tool.upgrade.core.installer import replace_binary +from cli_tool.upgrade.core.platform import detect_platform, get_binary_name, get_executable_path +from cli_tool.upgrade.core.version import get_current_version, get_latest_release + +__all__ = [ + "download_binary", + "verify_binary", + "replace_binary", + "detect_platform", + "get_binary_name", + "get_executable_path", + "get_current_version", + "get_latest_release", +] diff --git a/cli_tool/upgrade/core/downloader.py b/cli_tool/upgrade/core/downloader.py new file mode 100644 index 0000000..7072be4 --- /dev/null +++ b/cli_tool/upgrade/core/downloader.py @@ -0,0 +1,92 @@ +"""Binary download and verification.""" + +import sys +import tarfile +import zipfile + +import click +import requests + + +def verify_binary(binary_path, is_archive=False, archive_type=None): + """Verify downloaded binary is valid""" + try: + # For archive files (ZIP/tarball), verify format + if is_archive: + if archive_type == "zip": + if not zipfile.is_zipfile(binary_path): + click.echo("Error: Downloaded file is not a valid ZIP archive") + return False + # Check ZIP contains devo directory with devo.exe + with zipfile.ZipFile(binary_path, "r") as zf: + names = [name.replace("\\", "/") for name in zf.namelist()] + if not any("devo.exe" in name for name in names): + click.echo("Error: ZIP does not contain devo.exe") + return False + elif archive_type == "tar.gz": + if not tarfile.is_tarfile(binary_path): + click.echo("Error: Downloaded file is not a valid tar.gz archive") + return False + # Check tarball contains devo directory with devo executable + with tarfile.open(binary_path, "r:gz") as tf: + names = tf.getnames() + if not any("devo/devo" in name or name.endswith("/devo") for name in names): + click.echo("Error: tar.gz does not contain devo executable") + return False + return True + + # Check file size (should be at least 10MB for PyInstaller binary) + file_size = binary_path.stat().st_size + if file_size < 10 * 1024 * 1024: # 10MB + click.echo(f"Warning: Binary size is only {file_size / 1024 / 1024:.1f}MB, seems too small") + return False + + # Check if file is executable format (basic check) + with open(binary_path, "rb") as f: + magic = f.read(4) + # ELF magic for Linux: 7f 45 4c 46 + # Mach-O magic for macOS: cf fa ed fe or fe ed fa ce (and others) + # PE magic for Windows: 4d 5a (MZ) + if sys.platform.startswith("linux") and magic[:4] != b"\x7fELF": + click.echo("Error: Downloaded file is not a valid Linux ELF binary") + return False + elif sys.platform == "darwin" and magic[:4] not in [ + b"\xcf\xfa\xed\xfe", + b"\xfe\xed\xfa\xce", + b"\xce\xfa\xed\xfe", + b"\xfe\xed\xfa\xcf", + ]: + click.echo("Error: Downloaded file is not a valid macOS binary") + return False + elif sys.platform == "win32" and magic[:2] != b"MZ": + click.echo("Error: Downloaded file is not a valid Windows PE binary") + return False + + return True + except Exception as e: + click.echo(f"Error verifying binary: {str(e)}") + return False + + +def download_binary(url, dest_path): + """Download binary from URL with progress""" + try: + response = requests.get(url, stream=True, timeout=30) + response.raise_for_status() + + total_size = int(response.headers.get("content-length", 0)) + block_size = 8192 + downloaded = 0 + + with open(dest_path, "wb") as f: + with click.progressbar(length=total_size, label="Downloading", show_percent=True, show_pos=True) as bar: + for chunk in response.iter_content(chunk_size=block_size): + if chunk: + f.write(chunk) + downloaded += len(chunk) + bar.update(len(chunk)) + + return True + except Exception as e: + click.echo(f"\nError downloading binary: {str(e)}", err=True) + return False diff --git a/cli_tool/upgrade/core/installer.py b/cli_tool/upgrade/core/installer.py new file mode 100644 index 0000000..eb9c5dc --- /dev/null +++ b/cli_tool/upgrade/core/installer.py @@ -0,0 +1,165 @@ +"""Binary installation and replacement.""" + +import os +import platform +import shutil +import subprocess +import tarfile +import zipfile + +import click + + +def replace_binary(new_binary_path, target_path, archive_type=None): + """Replace current binary with new one""" + try: + system = platform.system().lower() + + # Handle archive extraction (Windows ZIP or macOS tarball) + if archive_type: + # Create backup of entire directory + backup_path = target_path.parent / f"{target_path.name}.backup" + if backup_path.exists(): + try: + shutil.rmtree(backup_path) + except OSError as e: + click.echo(f"Warning: Could not remove old backup: {e}", err=True) + + shutil.copytree(str(target_path), str(backup_path)) + click.echo(f"Backup created: {backup_path}") + + # Extract archive to temporary location + temp_extract = target_path.parent / "devo_new" + if temp_extract.exists(): + try: + shutil.rmtree(temp_extract) + except OSError as e: + click.echo(f"Warning: Could not remove old temp directory: {e}", err=True) + temp_extract.mkdir() + + try: + # Extract based on archive type + if archive_type == "zip": + with zipfile.ZipFile(new_binary_path, "r") as zf: + zf.extractall(temp_extract) + elif archive_type == "tar.gz": + with tarfile.open(new_binary_path, "r:gz") as tf: + tf.extractall(temp_extract) + + # Find the extracted devo directory + extracted_dir = None + for item in temp_extract.iterdir(): + if item.is_dir() and item.name.startswith("devo"): + extracted_dir = item + break + + if extracted_dir is None: + # Archive contains files at the root + extracted_dir = temp_extract + + if system == "windows": + # Windows: Use PowerShell script for replacement after exit + script_path = target_path.parent / "upgrade_devo.ps1" + script_content = f""" +# Wait for current process to exit +$processId = {os.getpid()} +Write-Host "Waiting for process $processId to exit..." +Wait-Process -Id $processId -ErrorAction SilentlyContinue + +# Give it a moment +Start-Sleep -Seconds 2 + +# Remove old installation +Write-Host "Removing old installation..." +if (Test-Path "{target_path}") {{ + Remove-Item -Path "{target_path}" -Recurse -Force -ErrorAction Stop +}} + +# Move new installation into place +Write-Host "Installing new version..." +Move-Item -Path "{extracted_dir}" -Destination "{target_path}" -Force -ErrorAction Stop + +# Clean up +Write-Host "Cleaning up..." +if (Test-Path "{temp_extract}") {{ + Remove-Item -Path "{temp_extract}" -Recurse -Force -ErrorAction SilentlyContinue +}} +Remove-Item -Path "{script_path}" -Force -ErrorAction SilentlyContinue + +Write-Host "Upgrade complete!" +Write-Host "You can now run: devo --version" +""" + + with open(script_path, "w", encoding="utf-8") as f: + f.write(script_content) + + click.echo("\n✨ Upgrade prepared successfully!") + click.echo("\nThe upgrade will complete after this process exits.") + click.echo("Starting upgrade script...") + + # Start the PowerShell script in a new window + subprocess.Popen( + ["powershell.exe", "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", str(script_path)], + creationflags=subprocess.CREATE_NEW_CONSOLE, + ) + + return True + else: + # macOS: Direct replacement (can replace while running) + # Remove old directory + shutil.rmtree(target_path) + + # Move new directory into place + shutil.move(str(extracted_dir), str(target_path)) + + # Make executable + exe_file = target_path / "devo" + os.chmod(exe_file, 0o755) + + # Clean up temp directory + if temp_extract.exists(): + try: + shutil.rmtree(temp_extract) + except OSError: + pass + + click.echo(f"\nBackup location: {backup_path}") + click.echo("\nTo restore backup if needed:") + click.echo(f" rm -rf {target_path}") + click.echo(f" mv {backup_path} {target_path}") + + return True + + except Exception as e: + click.echo(f"Error preparing upgrade: {e}", err=True) + # Clean up temp directory + if temp_extract.exists(): + try: + shutil.rmtree(temp_extract) + except OSError: + pass + return False + + # Linux: single binary replacement (onefile mode) + # Make new binary executable + os.chmod(new_binary_path, 0o755) + + # Always create a backup + backup_path = target_path.with_suffix(".backup") + if backup_path.exists(): + backup_path.unlink() + + # Copy current binary to backup + shutil.copy2(str(target_path), str(backup_path)) + click.echo(f"Backup created: {backup_path}") + + # Replace the file directly + shutil.move(str(new_binary_path), str(target_path)) + + click.echo("\nTo restore backup if needed:") + click.echo(f" mv {backup_path} {target_path}") + + return True + except Exception as e: + click.echo(f"Error replacing binary: {str(e)}", err=True) + return False diff --git a/cli_tool/upgrade/core/platform.py b/cli_tool/upgrade/core/platform.py new file mode 100644 index 0000000..ff420ba --- /dev/null +++ b/cli_tool/upgrade/core/platform.py @@ -0,0 +1,65 @@ +"""Platform detection for upgrade functionality.""" + +import platform +import shutil +import sys +from pathlib import Path + + +def detect_platform(): + """Detect current platform and architecture""" + system = platform.system().lower() + machine = platform.machine().lower() + + # Map system names + if system == "darwin": + system = "darwin" + elif system == "linux": + system = "linux" + elif system == "windows": + system = "windows" + else: + return None + + # Map architecture names + if machine in ["x86_64", "amd64"]: + arch = "amd64" + elif machine in ["arm64", "aarch64"]: + arch = "arm64" + else: + return None + + return system, arch + + +def get_binary_name(system, arch): + """Get binary name for platform""" + if system == "windows": + return f"devo-{system}-{arch}.zip" + elif system == "darwin": + # macOS uses tarball (onedir mode) + return f"devo-{system}-{arch}.tar.gz" + else: + # Linux uses single binary (onefile mode) + return f"devo-{system}-{arch}" + + +def get_executable_path(): + """Get path of current executable""" + # Check if running as PyInstaller bundle + if getattr(sys, "frozen", False): + exe_path = Path(sys.executable) + system = platform.system().lower() + + # Windows/macOS onedir: return the parent directory + if system in ["windows", "darwin"] and exe_path.name in ["devo.exe", "devo"]: + return exe_path.parent + # Linux onefile: return the executable itself + return exe_path + + # Running as Python script - find devo in PATH + devo_path = shutil.which("devo") + if devo_path: + return Path(devo_path) + + return None diff --git a/cli_tool/upgrade/core/version.py b/cli_tool/upgrade/core/version.py new file mode 100644 index 0000000..1504d19 --- /dev/null +++ b/cli_tool/upgrade/core/version.py @@ -0,0 +1,27 @@ +"""Version management for upgrade functionality.""" + +import click +import requests + + +def get_current_version(): + """Get current installed version""" + try: + from cli_tool._version import __version__ + + return __version__ + except ImportError: + return "unknown" + + +def get_latest_release(): + """Get latest release info from GitHub""" + try: + from cli_tool.config import GITHUB_API_RELEASES_URL + + response = requests.get(GITHUB_API_RELEASES_URL, timeout=10) + response.raise_for_status() + return response.json() + except Exception as e: + click.echo(f"Error fetching latest release: {str(e)}", err=True) + return None From 4560759f9f0e592bc3a074df6e202fcf8c36e314 Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Sun, 1 Mar 2026 23:39:08 -0500 Subject: [PATCH 04/37] refactor(autocomplete): restructure into modular command and core architecture - Reorganize completion command into `cli_tool/autocomplete/` with commands/ and core/ structure - Create `CompletionInstaller` core class for shell configuration management logic - Move CLI interaction and Click decorators to `autocomplete.py` command layer - Add comprehensive README documenting features, architecture, and usage examples - Rename "completion" to "autocomplete" for clarity and consistency - Update code organization steering document to reflect completed refactoring - Separate concerns: commands layer handles user interaction, core layer handles installation logic --- .kiro/steering/code-organization.md | 6 +- cli_tool/autocomplete/README.md | 128 +++++++++++++++++ cli_tool/autocomplete/__init__.py | 5 + cli_tool/autocomplete/commands/__init__.py | 5 + .../autocomplete/commands/autocomplete.py | 81 +++++++++++ cli_tool/autocomplete/core/__init__.py | 5 + cli_tool/autocomplete/core/installer.py | 113 +++++++++++++++ cli_tool/cli.py | 4 +- cli_tool/commands/completion.py | 130 +----------------- 9 files changed, 344 insertions(+), 133 deletions(-) create mode 100644 cli_tool/autocomplete/README.md create mode 100644 cli_tool/autocomplete/__init__.py create mode 100644 cli_tool/autocomplete/commands/__init__.py create mode 100644 cli_tool/autocomplete/commands/autocomplete.py create mode 100644 cli_tool/autocomplete/core/__init__.py create mode 100644 cli_tool/autocomplete/core/installer.py diff --git a/.kiro/steering/code-organization.md b/.kiro/steering/code-organization.md index 55a5643..4940ad4 100644 --- a/.kiro/steering/code-organization.md +++ b/.kiro/steering/code-organization.md @@ -173,10 +173,9 @@ def save_feature_config(feature_config: Dict): - `cli_tool/code_reviewer/` - Good separation with prompt/, tools/ - `cli_tool/aws_login/` - Reorganized with commands/, core/ - `cli_tool/upgrade/` - Reorganized with core/ (single command, no commands/ needed) +- `cli_tool/autocomplete/` - Reorganized with commands/, core/ ### ⚠️ Needs Refactoring -- `cli_tool/commands/upgrade.py` - Should be `cli_tool/upgrade/` with commands/, core/ -- `cli_tool/commands/completion.py` - Should be `cli_tool/completion/` with commands/, core/ - `cli_tool/commands/codeartifact_login.py` - Should be `cli_tool/codeartifact/` with commands/, core/ - `cli_tool/commands/commit_prompt.py` - Should be `cli_tool/commit/` with commands/, core/ - `cli_tool/commands/eventbridge.py` - Should be `cli_tool/eventbridge/` with commands/, core/ @@ -188,8 +187,7 @@ def save_feature_config(feature_config: Dict): 1. ✅ **SSM** - COMPLETED 2. ✅ **AWS Login** - COMPLETED 3. ✅ **Upgrade** - COMPLETED -4. **Completion** - Convert `cli_tool/commands/completion.py` → `cli_tool/completion/` -4. **Completion** - Convert `cli_tool/commands/completion.py` → `cli_tool/completion/` +4. ✅ **Autocomplete** - COMPLETED (renamed from completion) 5. **CodeArtifact** - Convert `cli_tool/commands/codeartifact_login.py` → `cli_tool/codeartifact/` 6. **Commit** - Convert `cli_tool/commands/commit_prompt.py` → `cli_tool/commit/` 7. **EventBridge** - Convert `cli_tool/commands/eventbridge.py` → `cli_tool/eventbridge/` diff --git a/cli_tool/autocomplete/README.md b/cli_tool/autocomplete/README.md new file mode 100644 index 0000000..aa924c5 --- /dev/null +++ b/cli_tool/autocomplete/README.md @@ -0,0 +1,128 @@ +# Shell Autocomplete + +Shell autocomplete support for Devo CLI using Click's built-in completion system. + +## Features + +- Auto-detects current shell (bash, zsh, fish) +- Shows manual installation instructions +- Automatic installation with `--install` flag +- Checks if autocomplete is already configured +- Supports confirmation prompts (can be skipped with `--yes`) + +## Usage + +### Show Instructions + +```bash +devo autocomplete +``` + +Detects your shell and shows manual installation instructions. + +### Auto-Install + +```bash +devo autocomplete --install +``` + +Automatically adds autocomplete to your shell config file with confirmation prompt. + +### Auto-Install (Skip Confirmation) + +```bash +devo autocomplete --install --yes +``` + +Installs without asking for confirmation. + +## Supported Shells + +- **bash** (version 4.4+) - Adds to `~/.bashrc` +- **zsh** - Adds to `~/.zshrc` +- **fish** - Adds to `~/.config/fish/config.fish` + +## Architecture + +### Commands Layer (`commands/`) + +- `autocomplete.py` - CLI command with Click decorators + - Detects shell from `$SHELL` environment variable + - Handles user interaction and output formatting + - Delegates logic to `CompletionInstaller` + +### Core Layer (`core/`) + +- `installer.py` - `CompletionInstaller` class + - Shell configuration management + - Installation logic + - Validation and checks + - No Click dependencies + +## How It Works + +1. Reads `$SHELL` environment variable +2. Extracts shell name (bash, zsh, fish) +3. Checks if shell is supported +4. Shows instructions or installs based on flags +5. Verifies if autocomplete is already configured +6. Adds completion line to appropriate config file + +## Completion Lines + +- **bash**: `eval "$(_DEVO_COMPLETE=bash_source devo)"` +- **zsh**: `eval "$(_DEVO_COMPLETE=zsh_source devo)"` +- **fish**: `_DEVO_COMPLETE=fish_source devo | source` + +## Configuration Files + +- **bash**: `~/.bashrc` +- **zsh**: `~/.zshrc` +- **fish**: `~/.config/fish/config.fish` + +## Examples + +### Manual Setup (bash) + +```bash +$ devo autocomplete +🔍 Detected shell: bash + +To enable shell completion in Bash, run: + + eval "$(_DEVO_COMPLETE=bash_source devo)" + +To make it permanent, add that line to your `~/.bashrc` file. + +💡 Tip: Use 'devo autocomplete --install' to set it up automatically +``` + +### Automatic Installation + +```bash +$ devo autocomplete --install +🔍 Detected shell: bash + +This will add the following line to /home/user/.bashrc: + eval "$(_DEVO_COMPLETE=bash_source devo)" + +Do you want to continue? [y/N]: y + +✅ Shell completion configured in /home/user/.bashrc + +💡 To activate it now, run: + source /home/user/.bashrc +``` + +### Already Configured + +```bash +$ devo autocomplete --install +🔍 Detected shell: bash + +✅ Shell completion already configured in /home/user/.bashrc +``` + +## References + +- [Click Shell Completion Documentation](https://click.palletsprojects.com/en/stable/shell-completion/) diff --git a/cli_tool/autocomplete/__init__.py b/cli_tool/autocomplete/__init__.py new file mode 100644 index 0000000..24bff2f --- /dev/null +++ b/cli_tool/autocomplete/__init__.py @@ -0,0 +1,5 @@ +"""Shell autocomplete management for Devo CLI.""" + +from cli_tool.autocomplete.commands.autocomplete import autocomplete + +__all__ = ["autocomplete"] diff --git a/cli_tool/autocomplete/commands/__init__.py b/cli_tool/autocomplete/commands/__init__.py new file mode 100644 index 0000000..5fc7b01 --- /dev/null +++ b/cli_tool/autocomplete/commands/__init__.py @@ -0,0 +1,5 @@ +"""Autocomplete commands.""" + +from cli_tool.autocomplete.commands.autocomplete import autocomplete + +__all__ = ["autocomplete"] diff --git a/cli_tool/autocomplete/commands/autocomplete.py b/cli_tool/autocomplete/commands/autocomplete.py new file mode 100644 index 0000000..b4d12fb --- /dev/null +++ b/cli_tool/autocomplete/commands/autocomplete.py @@ -0,0 +1,81 @@ +"""Shell autocomplete command.""" + +import os + +import click + +from cli_tool.autocomplete.core import CompletionInstaller + + +@click.command() +@click.option("--install", "-i", is_flag=True, help="Automatically install autocomplete to shell config") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt when installing") +def autocomplete(install, yes): + """Detects your shell and shows/installs shell autocomplete. + + By default, shows instructions for manual setup. + Use --install to automatically add autocomplete to your shell config. + """ + shell = os.environ.get("SHELL", "") + shell_name = os.path.basename(shell).lower().replace(".exe", "") + + click.echo(f"🔍 Detected shell: {shell_name}") + click.echo() + + if not CompletionInstaller.is_supported_shell(shell_name): + click.echo("⚠️ Your shell is not officially supported by Click.") + click.echo() + click.echo("Supported shells:") + click.echo(" • bash (version 4.4+)") + click.echo(" • zsh") + click.echo(" • fish") + click.echo() + click.echo("If you're using a different shell, you may need to:") + click.echo(" 1. Switch to a supported shell for completion") + click.echo(" 2. Check PyPI for third-party completion packages") + click.echo(" 3. Implement custom completion for your shell") + return + + # If --install flag is provided, setup automatically + if install: + _install_completion(shell_name, yes) + else: + _show_instructions(shell_name) + + +def _install_completion(shell_name: str, auto_confirm: bool): + """Install completion for shell.""" + rc_file = CompletionInstaller.get_config_file(shell_name) + completion_line = CompletionInstaller.get_completion_line(shell_name) + + # Check if already configured + if CompletionInstaller.is_already_configured(shell_name): + click.echo(f"✅ Shell completion already configured in {rc_file}") + return + + # Ask for confirmation unless auto_confirm is True + if not auto_confirm: + click.echo(f"\nThis will add the following line to {rc_file}:") + click.echo(f" {completion_line}") + click.echo() + if not click.confirm("Do you want to continue?"): + click.echo("Setup cancelled") + return + + # Install completion + success, message = CompletionInstaller.install(shell_name) + + if success: + click.echo(f"\n✅ {message}") + click.echo("\n💡 To activate it now, run:") + click.echo(f" source {rc_file}") + else: + click.echo(f"\n❌ {message}", err=True) + + +def _show_instructions(shell_name: str): + """Show manual installation instructions.""" + instructions = CompletionInstaller.get_instructions(shell_name) + click.echo(instructions) + click.echo() + click.echo("💡 Tip: Use 'devo autocomplete --install' to set it up automatically") diff --git a/cli_tool/autocomplete/core/__init__.py b/cli_tool/autocomplete/core/__init__.py new file mode 100644 index 0000000..11e1084 --- /dev/null +++ b/cli_tool/autocomplete/core/__init__.py @@ -0,0 +1,5 @@ +"""Core autocomplete logic.""" + +from cli_tool.autocomplete.core.installer import CompletionInstaller + +__all__ = ["CompletionInstaller"] diff --git a/cli_tool/autocomplete/core/installer.py b/cli_tool/autocomplete/core/installer.py new file mode 100644 index 0000000..d40a104 --- /dev/null +++ b/cli_tool/autocomplete/core/installer.py @@ -0,0 +1,113 @@ +"""Shell completion installer.""" + +from pathlib import Path +from typing import Optional + + +class CompletionInstaller: + """Manages shell completion installation.""" + + SHELL_CONFIGS = { + "bash": { + "line": 'eval "$(_DEVO_COMPLETE=bash_source devo)"', + "file": Path.home() / ".bashrc", + }, + "zsh": { + "line": 'eval "$(_DEVO_COMPLETE=zsh_source devo)"', + "file": Path.home() / ".zshrc", + }, + "fish": { + "line": "_DEVO_COMPLETE=fish_source devo | source", + "file": Path.home() / ".config" / "fish" / "config.fish", + }, + } + + SHELL_INSTRUCTIONS = { + "bash": """To enable shell completion in Bash, run: + + eval "$(_DEVO_COMPLETE=bash_source devo)" + +To make it permanent, add that line to your `~/.bashrc` file.""", + "zsh": """To enable shell completion in Zsh, run: + + eval "$(_DEVO_COMPLETE=zsh_source devo)" + +To make it permanent, add that line to your `~/.zshrc` file.""", + "fish": """To enable shell completion in Fish, run: + + _DEVO_COMPLETE=fish_source devo | source + +To make it permanent, add that line to your `~/.config/fish/config.fish` file.""", + } + + @classmethod + def is_supported_shell(cls, shell_name: str) -> bool: + """Check if shell is supported.""" + return shell_name in cls.SHELL_CONFIGS + + @classmethod + def get_instructions(cls, shell_name: str) -> Optional[str]: + """Get manual installation instructions for shell.""" + return cls.SHELL_INSTRUCTIONS.get(shell_name) + + @classmethod + def is_already_configured(cls, shell_name: str) -> bool: + """Check if completion is already configured for shell.""" + if shell_name not in cls.SHELL_CONFIGS: + return False + + rc_file = cls.SHELL_CONFIGS[shell_name]["file"] + if not rc_file.exists(): + return False + + content = rc_file.read_text() + return "_DEVO_COMPLETE" in content + + @classmethod + def install(cls, shell_name: str) -> tuple[bool, str]: + """Install completion for shell. + + Args: + shell_name: Name of the shell (bash, zsh, fish) + + Returns: + Tuple of (success, message) + """ + if shell_name not in cls.SHELL_CONFIGS: + return False, f"Unsupported shell: {shell_name}" + + config = cls.SHELL_CONFIGS[shell_name] + rc_file = config["file"] + completion_line = config["line"] + + # Check if already configured + if cls.is_already_configured(shell_name): + return True, f"Shell completion already configured in {rc_file}" + + try: + # Create parent directory if it doesn't exist (for fish) + rc_file.parent.mkdir(parents=True, exist_ok=True) + + # Add completion + with open(rc_file, "a") as f: + f.write("\n# Devo CLI completion\n") + f.write(f"{completion_line}\n") + + return True, f"Shell completion configured in {rc_file}" + + except Exception as e: + return False, f"Error setting up completion: {e}" + + @classmethod + def get_config_file(cls, shell_name: str) -> Optional[Path]: + """Get the config file path for shell.""" + if shell_name not in cls.SHELL_CONFIGS: + return None + return cls.SHELL_CONFIGS[shell_name]["file"] + + @classmethod + def get_completion_line(cls, shell_name: str) -> Optional[str]: + """Get the completion line for shell.""" + if shell_name not in cls.SHELL_CONFIGS: + return None + return cls.SHELL_CONFIGS[shell_name]["line"] diff --git a/cli_tool/cli.py b/cli_tool/cli.py index 7ab264b..d67ddd6 100644 --- a/cli_tool/cli.py +++ b/cli_tool/cli.py @@ -7,7 +7,7 @@ from cli_tool.commands.code_reviewer import code_reviewer from cli_tool.commands.codeartifact_login import codeartifact_login from cli_tool.commands.commit_prompt import commit -from cli_tool.commands.completion import completion +from cli_tool.commands.completion import autocomplete from cli_tool.commands.config import config_command from cli_tool.commands.dynamodb import dynamodb from cli_tool.commands.eventbridge import eventbridge @@ -104,7 +104,7 @@ def cli(ctx, profile): cli.add_command(upgrade) cli.add_command(aws_login) cli.add_command(codeartifact_login) -cli.add_command(completion) +cli.add_command(autocomplete) cli.add_command(code_reviewer) cli.add_command(config_command) cli.add_command(dynamodb) diff --git a/cli_tool/commands/completion.py b/cli_tool/commands/completion.py index 35cdb4a..e738552 100755 --- a/cli_tool/commands/completion.py +++ b/cli_tool/commands/completion.py @@ -1,129 +1,5 @@ -import os -from pathlib import Path +"""Thin wrapper for autocomplete command - imports from cli_tool.autocomplete.""" -import click +from cli_tool.autocomplete import autocomplete -# https://click.palletsprojects.com/en/stable/shell-completion/ -SHELL_INSTRUCTIONS = { - "bash": """To enable shell completion in Bash, run: - - eval "$(_DEVO_COMPLETE=bash_source devo)" - -To make it permanent, add that line to your `~/.bashrc` file.""", - "zsh": """To enable shell completion in Zsh, run: - - eval "$(_DEVO_COMPLETE=zsh_source devo)" - -To make it permanent, add that line to your `~/.zshrc` file.""", - "fish": """To enable shell completion in Fish, run: - - _DEVO_COMPLETE=fish_source devo | source - -To make it permanent, add that line to your `~/.config/fish/config.fish` file.""", -} - - -def setup_completion_for_shell(shell_name: str, auto_confirm: bool = False) -> bool: - """Setup shell completion for the given shell. - - Args: - shell_name: Name of the shell (bash, zsh, fish) - auto_confirm: If True, skip confirmation prompt - - Returns: - True if setup was successful, False otherwise - """ - completion_configs = { - "bash": {"line": 'eval "$(_DEVO_COMPLETE=bash_source devo)"', "file": Path.home() / ".bashrc"}, - "zsh": {"line": 'eval "$(_DEVO_COMPLETE=zsh_source devo)"', "file": Path.home() / ".zshrc"}, - "fish": {"line": "_DEVO_COMPLETE=fish_source devo | source", "file": Path.home() / ".config" / "fish" / "config.fish"}, - } - - if shell_name not in completion_configs: - return False - - config = completion_configs[shell_name] - rc_file = config["file"] - completion_line = config["line"] - - # Check if already configured - if rc_file.exists(): - content = rc_file.read_text() - if "_DEVO_COMPLETE" in content: - click.echo(f"✅ Shell completion already configured in {rc_file}") - return True - - # Ask for confirmation unless auto_confirm is True - if not auto_confirm: - click.echo(f"\nThis will add the following line to {rc_file}:") - click.echo(f" {completion_line}") - click.echo() - if not click.confirm("Do you want to continue?"): - click.echo("Setup cancelled") - return False - - try: - # Create parent directory if it doesn't exist (for fish) - rc_file.parent.mkdir(parents=True, exist_ok=True) - - # Add completion - with open(rc_file, "a") as f: - f.write("\n# Devo CLI completion\n") - f.write(f"{completion_line}\n") - - click.echo(f"\n✅ Shell completion configured in {rc_file}") - click.echo("\n💡 To activate it now, run:") - click.echo(f" source {rc_file}") - return True - - except Exception as e: - click.echo(f"\n❌ Error setting up completion: {e}", err=True) - return False - - -@click.group() -def cli(): - pass - - -@cli.command() -@click.option("--install", "-i", is_flag=True, help="Automatically install completion to shell config") -@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt when installing") -def completion(install, yes): - """Detects your shell and shows/installs shell completion. - - By default, shows instructions for manual setup. - Use --install to automatically add completion to your shell config. - """ - shell = os.environ.get("SHELL", "") - shell_name = os.path.basename(shell).lower().replace(".exe", "") - - click.echo(f"🔍 Detected shell: {shell_name}") - click.echo() - - if shell_name not in SHELL_INSTRUCTIONS: - click.echo("⚠️ Your shell is not officially supported by Click.") - click.echo() - click.echo("Supported shells:") - click.echo(" • bash (version 4.4+)") - click.echo(" • zsh") - click.echo(" • fish") - click.echo() - click.echo("If you're using a different shell, you may need to:") - click.echo(" 1. Switch to a supported shell for completion") - click.echo(" 2. Check PyPI for third-party completion packages") - click.echo(" 3. Implement custom completion for your shell") - return - - # If --install flag is provided, setup automatically - if install: - setup_completion_for_shell(shell_name, auto_confirm=yes) - else: - # Show manual instructions - click.echo(SHELL_INSTRUCTIONS[shell_name]) - click.echo() - click.echo("💡 Tip: Use 'devo completion --install' to set it up automatically") - - -if __name__ == "__main__": - cli() +__all__ = ["autocomplete"] From 2a41211c6c7cbf4acd2417906b0d509f71fa6904 Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Mon, 2 Mar 2026 00:03:33 -0500 Subject: [PATCH 05/37] refactor(codeartifact): restructure into modular command and core architecture - Move codeartifact_login from cli_tool/commands/ to cli_tool/codeartifact/commands/login.py - Create modular structure with commands/ and core/ subdirectories - Extract authentication logic into cli_tool/codeartifact/core/authenticator.py - Add __init__.py files for proper module exports - Update cli.py import to use new codeartifact module path - Update code-organization.md to mark CodeArtifact refactoring as completed - Follows established modular architecture pattern used by aws_login, upgrade, and autocomplete --- .kiro/steering/code-organization.md | 4 +- cli_tool/cli.py | 2 +- cli_tool/codeartifact/__init__.py | 5 + cli_tool/codeartifact/commands/__init__.py | 5 + cli_tool/codeartifact/commands/login.py | 183 +++++++++++++ cli_tool/codeartifact/core/__init__.py | 5 + cli_tool/codeartifact/core/authenticator.py | 168 ++++++++++++ cli_tool/commands/codeartifact_login.py | 273 +------------------- 8 files changed, 374 insertions(+), 271 deletions(-) create mode 100644 cli_tool/codeartifact/__init__.py create mode 100644 cli_tool/codeartifact/commands/__init__.py create mode 100644 cli_tool/codeartifact/commands/login.py create mode 100644 cli_tool/codeartifact/core/__init__.py create mode 100644 cli_tool/codeartifact/core/authenticator.py diff --git a/.kiro/steering/code-organization.md b/.kiro/steering/code-organization.md index 4940ad4..0a2d31f 100644 --- a/.kiro/steering/code-organization.md +++ b/.kiro/steering/code-organization.md @@ -174,9 +174,9 @@ def save_feature_config(feature_config: Dict): - `cli_tool/aws_login/` - Reorganized with commands/, core/ - `cli_tool/upgrade/` - Reorganized with core/ (single command, no commands/ needed) - `cli_tool/autocomplete/` - Reorganized with commands/, core/ +- `cli_tool/codeartifact/` - Reorganized with commands/, core/ ### ⚠️ Needs Refactoring -- `cli_tool/commands/codeartifact_login.py` - Should be `cli_tool/codeartifact/` with commands/, core/ - `cli_tool/commands/commit_prompt.py` - Should be `cli_tool/commit/` with commands/, core/ - `cli_tool/commands/eventbridge.py` - Should be `cli_tool/eventbridge/` with commands/, core/ - `cli_tool/commands/config.py` - Should be `cli_tool/config_cmd/` with commands/, core/ @@ -188,7 +188,7 @@ def save_feature_config(feature_config: Dict): 2. ✅ **AWS Login** - COMPLETED 3. ✅ **Upgrade** - COMPLETED 4. ✅ **Autocomplete** - COMPLETED (renamed from completion) -5. **CodeArtifact** - Convert `cli_tool/commands/codeartifact_login.py` → `cli_tool/codeartifact/` +5. ✅ **CodeArtifact** - COMPLETED 6. **Commit** - Convert `cli_tool/commands/commit_prompt.py` → `cli_tool/commit/` 7. **EventBridge** - Convert `cli_tool/commands/eventbridge.py` → `cli_tool/eventbridge/` 8. **Config** - Convert `cli_tool/commands/config.py` → `cli_tool/config_cmd/` diff --git a/cli_tool/cli.py b/cli_tool/cli.py index d67ddd6..12467d9 100644 --- a/cli_tool/cli.py +++ b/cli_tool/cli.py @@ -3,9 +3,9 @@ import click from rich.console import Console +from cli_tool.codeartifact import codeartifact_login from cli_tool.commands.aws_login import aws_login from cli_tool.commands.code_reviewer import code_reviewer -from cli_tool.commands.codeartifact_login import codeartifact_login from cli_tool.commands.commit_prompt import commit from cli_tool.commands.completion import autocomplete from cli_tool.commands.config import config_command diff --git a/cli_tool/codeartifact/__init__.py b/cli_tool/codeartifact/__init__.py new file mode 100644 index 0000000..37d0e9e --- /dev/null +++ b/cli_tool/codeartifact/__init__.py @@ -0,0 +1,5 @@ +"""CodeArtifact authentication module.""" + +from cli_tool.codeartifact.commands.login import codeartifact_login + +__all__ = ["codeartifact_login"] diff --git a/cli_tool/codeartifact/commands/__init__.py b/cli_tool/codeartifact/commands/__init__.py new file mode 100644 index 0000000..edcd862 --- /dev/null +++ b/cli_tool/codeartifact/commands/__init__.py @@ -0,0 +1,5 @@ +"""CodeArtifact CLI commands.""" + +from cli_tool.codeartifact.commands.login import codeartifact_login + +__all__ = ["codeartifact_login"] diff --git a/cli_tool/codeartifact/commands/login.py b/cli_tool/codeartifact/commands/login.py new file mode 100644 index 0000000..5c29806 --- /dev/null +++ b/cli_tool/codeartifact/commands/login.py @@ -0,0 +1,183 @@ +"""CodeArtifact login command.""" + +import sys + +import click +from rich.console import Console + +from cli_tool.codeartifact.core.authenticator import CodeArtifactAuthenticator +from cli_tool.config import ( + AWS_SSO_URL, + CODEARTIFACT_DOMAINS, + CODEARTIFACT_REGION, +) +from cli_tool.utils.aws import check_aws_cli +from cli_tool.utils.aws_profile import ( + REQUIRED_ACCOUNT, + REQUIRED_ROLE, + verify_aws_credentials, +) + +console = Console() + + +@click.command() +@click.pass_context +def codeartifact_login(ctx): + """Login to AWS CodeArtifact for npm access. + + Authenticates with configured CodeArtifact domains and repositories. + Supports multiple domains with different namespaces. + + Examples: + devo codeartifact-login + devo --profile my-profile codeartifact-login + """ + from cli_tool.utils.aws import select_profile + + # Get profile from context or prompt user to select + profile = select_profile(ctx.obj.get("profile")) + + click.echo(click.style("=== CodeArtifact Multi-Domain Login ===", fg="green")) + click.echo("") + click.echo(click.style(f"Required AWS Account: {REQUIRED_ACCOUNT}", fg="blue")) + click.echo(click.style(f"Required IAM Role: {REQUIRED_ROLE}", fg="blue")) + click.echo("") + + # Check if AWS CLI is installed + if not check_aws_cli(): + sys.exit(1) + + # Verify credentials and account with spinner + with console.status("[blue]Verifying AWS credentials...", spinner="dots"): + account_id, user_arn = verify_aws_credentials(profile) + + if not account_id: + click.echo(click.style("No AWS credentials found", fg="red")) + click.echo("") + click.echo(click.style("Get your AWS credentials from:", fg="blue")) + click.echo(f" {AWS_SSO_URL}") + sys.exit(1) + + if account_id != REQUIRED_ACCOUNT: + click.echo(click.style(f"Current credentials are for account: {account_id}", fg="yellow")) + click.echo(click.style(f"Required account: {REQUIRED_ACCOUNT}", fg="yellow")) + click.echo("") + click.echo(click.style("Get credentials for the correct account from:", fg="blue")) + click.echo(f" {AWS_SSO_URL}") + sys.exit(1) + + # Display credential info + if profile: + click.echo(click.style(f"Using profile: {profile}", fg="green")) + else: + click.echo(click.style("Using active AWS credentials", fg="green")) + + click.echo(click.style(f"Account: {account_id}", fg="blue")) + click.echo(click.style(f"User: {user_arn}", fg="blue")) + click.echo("") + + # Check if user has the required role + if REQUIRED_ROLE not in user_arn: + click.echo( + click.style( + f"Warning: User ARN does not contain '{REQUIRED_ROLE}' role", + fg="yellow", + ) + ) + click.echo("Please ensure you have the necessary CodeArtifact permissions") + click.echo("") + if not click.confirm("Continue anyway?"): + click.echo("Aborted") + sys.exit(1) + + click.echo(click.style(f"Region: {CODEARTIFACT_REGION}", fg="blue")) + click.echo("") + + # Initialize authenticator + authenticator = CodeArtifactAuthenticator(CODEARTIFACT_REGION, CODEARTIFACT_DOMAINS) + + # Track success/failure + success_count = 0 + failure_count = 0 + failed_domains = [] + + # Login to each domain + for domain, repository, namespace in CODEARTIFACT_DOMAINS: + with console.status( + f"[yellow]Authenticating with {domain}/{repository} ({namespace})...", + spinner="dots", + ): + success, error = authenticator.authenticate_domain(domain, repository, namespace, profile) + + if success: + click.echo( + click.style( + f"✓ Successfully authenticated with {domain}/{repository} ({namespace})", + fg="green", + ) + ) + success_count += 1 + else: + click.echo( + click.style( + f"✗ Failed to authenticate with {domain}/{repository} ({namespace})", + fg="red", + ) + ) + if error: + click.echo(error) + failure_count += 1 + failed_domains.append(f"{domain}/{repository} ({namespace})") + + click.echo("") + + # Summary + click.echo(click.style("=== Authentication Summary ===", fg="green")) + click.echo("") + click.echo(click.style(f"Successful: {success_count}", fg="green")) + + if failure_count > 0: + click.echo(click.style(f"Failed: {failure_count}", fg="red")) + click.echo("") + click.echo("Failed domains:") + for domain in failed_domains: + click.echo(f" - {domain}") + click.echo("") + click.echo("Troubleshooting:") + click.echo(f" 1. Verify you're using account {REQUIRED_ACCOUNT}: aws sts get-caller-identity") + click.echo(f" 2. Ensure you have the {REQUIRED_ROLE} role with CodeArtifact permissions") + click.echo(" 3. Check IAM permissions for CodeArtifact (GetAuthorizationToken, ReadFromRepository)") + click.echo(" 4. Ensure the domains and repositories exist") + click.echo("") + click.echo(click.style("Get fresh credentials from:", fg="blue")) + click.echo(f" {AWS_SSO_URL}") + sys.exit(1) + + if success_count > 0: + click.echo("") + click.echo(click.style("Note: Tokens expire in 12 hours", fg="yellow")) + click.echo(click.style("Note: pnpm will automatically use the npm configuration", fg="yellow")) + click.echo("") + + # List available packages from each domain + click.echo(click.style("=== Available Packages ===", fg="green")) + click.echo("") + + for domain, repository, namespace in CODEARTIFACT_DOMAINS: + click.echo(click.style(f"Domain: {domain} ({namespace})", fg="blue")) + + with console.status(f"[blue]Fetching packages from {domain}...", spinner="dots"): + packages = authenticator.list_packages(domain, repository, profile) + + if packages: + for package in packages: + version = authenticator.get_package_version(domain, repository, package, namespace, profile) + if version: + click.echo(f" - {namespace}/{package}@{version}") + else: + click.echo(f" - {namespace}/{package}") + else: + click.echo(" No packages found") + + click.echo("") diff --git a/cli_tool/codeartifact/core/__init__.py b/cli_tool/codeartifact/core/__init__.py new file mode 100644 index 0000000..038a946 --- /dev/null +++ b/cli_tool/codeartifact/core/__init__.py @@ -0,0 +1,5 @@ +"""CodeArtifact core business logic.""" + +from cli_tool.codeartifact.core.authenticator import CodeArtifactAuthenticator + +__all__ = ["CodeArtifactAuthenticator"] diff --git a/cli_tool/codeartifact/core/authenticator.py b/cli_tool/codeartifact/core/authenticator.py new file mode 100644 index 0000000..3e73eb7 --- /dev/null +++ b/cli_tool/codeartifact/core/authenticator.py @@ -0,0 +1,168 @@ +"""CodeArtifact authentication business logic.""" + +import subprocess +from typing import List, Optional, Tuple + + +class CodeArtifactAuthenticator: + """Handles CodeArtifact authentication operations.""" + + def __init__(self, region: str, domains: List[Tuple[str, str, str]]): + """Initialize authenticator. + + Args: + region: AWS region for CodeArtifact + domains: List of (domain, repository, namespace) tuples + """ + self.region = region + self.domains = domains + + def authenticate_domain( + self, + domain: str, + repository: str, + namespace: str, + profile: Optional[str] = None, + timeout: int = 30, + ) -> Tuple[bool, Optional[str]]: + """Authenticate with a single CodeArtifact domain. + + Args: + domain: CodeArtifact domain name + repository: Repository name + namespace: Package namespace + profile: AWS profile to use + timeout: Command timeout in seconds + + Returns: + Tuple of (success, error_message) + """ + cmd = [ + "aws", + "codeartifact", + "login", + "--tool", + "npm", + "--domain", + domain, + "--repository", + repository, + "--namespace", + namespace, + "--region", + self.region, + ] + + if profile: + cmd.extend(["--profile", profile]) + + try: + subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=timeout) + return True, None + except subprocess.TimeoutExpired: + return False, "Timeout" + except subprocess.CalledProcessError as e: + return False, e.stderr if e.stderr else "Authentication failed" + + def list_packages( + self, + domain: str, + repository: str, + profile: Optional[str] = None, + timeout: int = 10, + ) -> List[str]: + """List packages in a CodeArtifact repository. + + Args: + domain: CodeArtifact domain name + repository: Repository name + profile: AWS profile to use + timeout: Command timeout in seconds + + Returns: + List of package names + """ + cmd = [ + "aws", + "codeartifact", + "list-packages", + "--domain", + domain, + "--repository", + repository, + "--region", + self.region, + "--format", + "npm", + "--query", + "packages[].package", + "--output", + "text", + ] + + if profile: + cmd.extend(["--profile", profile]) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if result.returncode == 0 and result.stdout.strip(): + return [pkg for pkg in result.stdout.strip().split("\t") if pkg] + return [] + except Exception: + return [] + + def get_package_version( + self, + domain: str, + repository: str, + package: str, + namespace: str, + profile: Optional[str] = None, + timeout: int = 5, + ) -> Optional[str]: + """Get the latest version of a package. + + Args: + domain: CodeArtifact domain name + repository: Repository name + package: Package name + namespace: Package namespace + profile: AWS profile to use + timeout: Command timeout in seconds + + Returns: + Latest version string or None + """ + cmd = [ + "aws", + "codeartifact", + "list-package-versions", + "--domain", + domain, + "--repository", + repository, + "--format", + "npm", + "--package", + package, + "--namespace", + namespace, + "--region", + self.region, + "--query", + "versions[0].version", + "--output", + "text", + ] + + if profile: + cmd.extend(["--profile", profile]) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if result.returncode == 0 and result.stdout.strip(): + version = result.stdout.strip() + return version if version and version != "None" else None + return None + except Exception: + return None diff --git a/cli_tool/commands/codeartifact_login.py b/cli_tool/commands/codeartifact_login.py index c508b16..066b453 100644 --- a/cli_tool/commands/codeartifact_login.py +++ b/cli_tool/commands/codeartifact_login.py @@ -1,271 +1,8 @@ -import subprocess -import sys +"""Thin wrapper for backward compatibility. -import click -from rich.console import Console +DEPRECATED: Import from cli_tool.codeartifact instead. +""" -from cli_tool.config import AWS_SSO_URL, CODEARTIFACT_DOMAINS, CODEARTIFACT_REGION -from cli_tool.utils.aws import check_aws_cli -from cli_tool.utils.aws_profile import ( - REQUIRED_ACCOUNT, - REQUIRED_ROLE, - verify_aws_credentials, -) +from cli_tool.codeartifact import codeartifact_login -# Configuration -REGION = CODEARTIFACT_REGION -DOMAINS = CODEARTIFACT_DOMAINS - -console = Console() - - -@click.command() -@click.pass_context -def codeartifact_login(ctx): - """Login to AWS CodeArtifact for npm access. - - Authenticates with configured CodeArtifact domains and repositories. - Supports multiple domains with different namespaces. - - Examples: - devo codeartifact-login - devo --profile my-profile codeartifact-login - """ - from cli_tool.utils.aws import select_profile - - # Get profile from context or prompt user to select - profile = select_profile(ctx.obj.get("profile")) - - click.echo(click.style("=== CodeArtifact Multi-Domain Login ===", fg="green")) - click.echo("") - click.echo(click.style(f"Required AWS Account: {REQUIRED_ACCOUNT}", fg="blue")) - click.echo(click.style(f"Required IAM Role: {REQUIRED_ROLE}", fg="blue")) - click.echo("") - - # Check if AWS CLI is installed - if not check_aws_cli(): - sys.exit(1) - - # Verify credentials and account with spinner - with console.status("[blue]Verifying AWS credentials...", spinner="dots"): - account_id, user_arn = verify_aws_credentials(profile) - - if not account_id: - click.echo(click.style("No AWS credentials found", fg="red")) - click.echo("") - click.echo(click.style("Get your AWS credentials from:", fg="blue")) - click.echo(f" {AWS_SSO_URL}") - sys.exit(1) - - if account_id != REQUIRED_ACCOUNT: - click.echo(click.style(f"Current credentials are for account: {account_id}", fg="yellow")) - click.echo(click.style(f"Required account: {REQUIRED_ACCOUNT}", fg="yellow")) - click.echo("") - click.echo(click.style("Get credentials for the correct account from:", fg="blue")) - click.echo(f" {AWS_SSO_URL}") - sys.exit(1) - - # Display credential info - if profile: - click.echo(click.style(f"Using profile: {profile}", fg="green")) - else: - click.echo(click.style("Using active AWS credentials", fg="green")) - - click.echo(click.style(f"Account: {account_id}", fg="blue")) - click.echo(click.style(f"User: {user_arn}", fg="blue")) - click.echo("") - - # Check if user has the required role - if REQUIRED_ROLE not in user_arn: - click.echo( - click.style( - f"Warning: User ARN does not contain '{REQUIRED_ROLE}' role", - fg="yellow", - ) - ) - click.echo("Please ensure you have the necessary CodeArtifact permissions") - click.echo("") - if not click.confirm("Continue anyway?"): - click.echo("Aborted") - sys.exit(1) - - click.echo(click.style(f"Region: {REGION}", fg="blue")) - click.echo("") - - # Track success/failure - success_count = 0 - failure_count = 0 - failed_domains = [] - - # Login to each domain - for domain, repository, namespace in DOMAINS: - cmd = [ - "aws", - "codeartifact", - "login", - "--tool", - "npm", - "--domain", - domain, - "--repository", - repository, - "--namespace", - namespace, - "--region", - REGION, - ] - - if profile: - cmd.extend(["--profile", profile]) - - with console.status( - f"[yellow]Authenticating with {domain}/{repository} ({namespace})...", - spinner="dots", - ): - try: - result = subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=30) - click.echo( - click.style( - f"✓ Successfully authenticated with {domain}/{repository} ({namespace})", - fg="green", - ) - ) - success_count += 1 - except subprocess.TimeoutExpired: - click.echo( - click.style( - f"✗ Timeout authenticating with {domain}/{repository} ({namespace})", - fg="red", - ) - ) - failure_count += 1 - failed_domains.append(f"{domain}/{repository} ({namespace})") - except subprocess.CalledProcessError as e: - click.echo( - click.style( - f"✗ Failed to authenticate with {domain}/{repository} ({namespace})", - fg="red", - ) - ) - if e.stderr: - click.echo(e.stderr) - failure_count += 1 - failed_domains.append(f"{domain}/{repository} ({namespace})") - click.echo("") - - # Summary - click.echo(click.style("=== Authentication Summary ===", fg="green")) - click.echo("") - click.echo(click.style(f"Successful: {success_count}", fg="green")) - - if failure_count > 0: - click.echo(click.style(f"Failed: {failure_count}", fg="red")) - click.echo("") - click.echo("Failed domains:") - for domain in failed_domains: - click.echo(f" - {domain}") - click.echo("") - click.echo("Troubleshooting:") - click.echo(f" 1. Verify you're using account {REQUIRED_ACCOUNT}: aws sts get-caller-identity") - click.echo(f" 2. Ensure you have the {REQUIRED_ROLE} role with CodeArtifact permissions") - click.echo(" 3. Check IAM permissions for CodeArtifact (GetAuthorizationToken, ReadFromRepository)") - click.echo(" 4. Ensure the domains and repositories exist") - click.echo("") - click.echo(click.style("Get fresh credentials from:", fg="blue")) - click.echo(f" {AWS_SSO_URL}") - sys.exit(1) - - if success_count > 0: - click.echo("") - click.echo(click.style("Note: Tokens expire in 12 hours", fg="yellow")) - click.echo(click.style("Note: pnpm will automatically use the npm configuration", fg="yellow")) - click.echo("") - - # List available packages from each domain - click.echo(click.style("=== Available Packages ===", fg="green")) - click.echo("") - - for domain, repository, namespace in DOMAINS: - click.echo(click.style(f"Domain: {domain} ({namespace})", fg="blue")) - - list_cmd = [ - "aws", - "codeartifact", - "list-packages", - "--domain", - domain, - "--repository", - repository, - "--region", - REGION, - "--format", - "npm", - "--query", - "packages[].package", - "--output", - "text", - ] - - if profile: - list_cmd.extend(["--profile", profile]) - - with console.status(f"[blue]Fetching packages from {domain}...", spinner="dots"): - try: - result = subprocess.run(list_cmd, capture_output=True, text=True, timeout=10) - if result.returncode == 0 and result.stdout.strip(): - packages = result.stdout.strip().split("\t") - for package in packages: - if package: - # Try to get latest version - version_cmd = [ - "aws", - "codeartifact", - "list-package-versions", - "--domain", - domain, - "--repository", - repository, - "--format", - "npm", - "--package", - package, - "--namespace", - namespace, - "--region", - REGION, - "--query", - "versions[0].version", - "--output", - "text", - ] - - if profile: - version_cmd.extend(["--profile", profile]) - - try: - version_result = subprocess.run( - version_cmd, - capture_output=True, - text=True, - timeout=5, - ) - if version_result.returncode == 0 and version_result.stdout.strip(): - version = version_result.stdout.strip() - if version and version != "None": - click.echo(f" - {namespace}/{package}@{version}") - else: - click.echo(f" - {namespace}/{package}") - else: - click.echo(f" - {namespace}/{package}") - except Exception: - click.echo(f" - {namespace}/{package}") - else: - click.echo(" No packages found") - except Exception: - click.echo(" Could not list packages") - - click.echo("") - - -if __name__ == "__main__": - codeartifact_login() +__all__ = ["codeartifact_login"] From 584a21de801bd7910c08370eac5f44a80d017cae Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Mon, 2 Mar 2026 00:06:51 -0500 Subject: [PATCH 06/37] refactor(commit): restructure into modular command and core architecture - Split commit functionality into commands/ and core/ modules - Keep backward compatibility through import redirect - Update imports in main CLI file --- .kiro/steering/code-organization.md | 4 +- cli_tool/cli.py | 2 +- cli_tool/commands/commit_prompt.py | 209 +-------------------------- cli_tool/commit/__init__.py | 5 + cli_tool/commit/commands/__init__.py | 5 + cli_tool/commit/commands/generate.py | 98 +++++++++++++ cli_tool/commit/core/__init__.py | 5 + cli_tool/commit/core/generator.py | 206 ++++++++++++++++++++++++++ 8 files changed, 327 insertions(+), 207 deletions(-) create mode 100644 cli_tool/commit/__init__.py create mode 100644 cli_tool/commit/commands/__init__.py create mode 100644 cli_tool/commit/commands/generate.py create mode 100644 cli_tool/commit/core/__init__.py create mode 100644 cli_tool/commit/core/generator.py diff --git a/.kiro/steering/code-organization.md b/.kiro/steering/code-organization.md index 0a2d31f..df6f2a6 100644 --- a/.kiro/steering/code-organization.md +++ b/.kiro/steering/code-organization.md @@ -175,9 +175,9 @@ def save_feature_config(feature_config: Dict): - `cli_tool/upgrade/` - Reorganized with core/ (single command, no commands/ needed) - `cli_tool/autocomplete/` - Reorganized with commands/, core/ - `cli_tool/codeartifact/` - Reorganized with commands/, core/ +- `cli_tool/commit/` - Reorganized with commands/, core/ ### ⚠️ Needs Refactoring -- `cli_tool/commands/commit_prompt.py` - Should be `cli_tool/commit/` with commands/, core/ - `cli_tool/commands/eventbridge.py` - Should be `cli_tool/eventbridge/` with commands/, core/ - `cli_tool/commands/config.py` - Should be `cli_tool/config_cmd/` with commands/, core/ @@ -189,7 +189,7 @@ def save_feature_config(feature_config: Dict): 3. ✅ **Upgrade** - COMPLETED 4. ✅ **Autocomplete** - COMPLETED (renamed from completion) 5. ✅ **CodeArtifact** - COMPLETED -6. **Commit** - Convert `cli_tool/commands/commit_prompt.py` → `cli_tool/commit/` +6. ✅ **Commit** - COMPLETED 7. **EventBridge** - Convert `cli_tool/commands/eventbridge.py` → `cli_tool/eventbridge/` 8. **Config** - Convert `cli_tool/commands/config.py` → `cli_tool/config_cmd/` diff --git a/cli_tool/cli.py b/cli_tool/cli.py index 12467d9..53edcbd 100644 --- a/cli_tool/cli.py +++ b/cli_tool/cli.py @@ -6,13 +6,13 @@ from cli_tool.codeartifact import codeartifact_login from cli_tool.commands.aws_login import aws_login from cli_tool.commands.code_reviewer import code_reviewer -from cli_tool.commands.commit_prompt import commit from cli_tool.commands.completion import autocomplete from cli_tool.commands.config import config_command from cli_tool.commands.dynamodb import dynamodb from cli_tool.commands.eventbridge import eventbridge from cli_tool.commands.ssm import ssm from cli_tool.commands.upgrade import upgrade +from cli_tool.commit import commit console = Console() diff --git a/cli_tool/commands/commit_prompt.py b/cli_tool/commands/commit_prompt.py index 66f678a..6885049 100644 --- a/cli_tool/commands/commit_prompt.py +++ b/cli_tool/commands/commit_prompt.py @@ -1,207 +1,8 @@ -import re -import subprocess -import webbrowser +"""Thin wrapper for backward compatibility. -import click +DEPRECATED: Import from cli_tool.commit instead. +""" -from cli_tool.agents.base_agent import BaseAgent -from cli_tool.utils.aws import select_profile -from cli_tool.utils.git_utils import get_branch_name, get_remote_url, get_staged_diff +from cli_tool.commit import commit - -@click.command() -@click.option("--push", "-p", is_flag=True, help="Push the commit to the remote origin.") -@click.option("--pull-request", "-pr", is_flag=True, help="Open a pull request on GitHub.") -@click.option( - "--add", - "-a", - is_flag=True, - help="Add all changes to the staging area before committing.", -) -@click.option( - "--all", - "-A", - is_flag=True, - help="Perform add, commit, push, and open pull request.", -) -@click.pass_context -def commit(ctx, push, pull_request, add, all): - """Generate a commit message based on staged changes with AI.""" - if all: - add = True - push = True - pull_request = True - - if add: - click.echo("Adding all changes to the staging area...") - subprocess.run(["git", "add", "."], check=True) - click.echo("✅ All changes added.") - - # Get profile from context or prompt user to select - ctx.ensure_object(dict) - profile = select_profile(ctx.obj.get("profile")) - - agent_ai = BaseAgent( - name="CommitMessageGenerator", - system_prompt="""You are an AI that generates ONE conventional commit message. - -CRITICAL: Generate ONLY ONE commit message, not multiple. Find the PRIMARY purpose of all changes. - -FORMAT (first line): -type(scope): summary - -VALID TYPES: feat, fix, refactor, chore, docs, test, style, perf - -RULES: -1. First line: type(scope): summary (max 50 chars) -2. Summary explains WHY, not what -3. Optional: blank line + bullet points with details -4. NO markdown, NO multiple messages, NO explanations - -CORRECT EXAMPLES: -refactor(agents): simplify commit message generation - -fix(auth): resolve credential validation error - -- Fixed caching logic -- Added error handling - -INCORRECT (DO NOT DO): -❌ Multiple commit messages listed -❌ refactor(agents): improve X - fix(base): enhance Y - refactor(commit): replace Z - -Generate ONE message capturing the main purpose of ALL changes.""", - profile_name=profile, - enable_rich_logging=False, # Disable rich logging to avoid duplicate output - ) - diff_text = get_staged_diff() - if not diff_text: - click.echo("No staged changes found.") - return - - branch_name = get_branch_name() - # Extract ticket number from branch name (e.g., feature/TICKET-123-description, fix/PROJ-456-desc) - match = re.match(r"(?:feature|fix|chore)/([A-Za-z0-9]+-\d+)", branch_name) - ticket_number = match.group(1) if match else None - - # Get additional git context for better commit message generation - try: - # Get git status to understand what files are being committed - git_status_result = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True, check=True) - git_status = git_status_result.stdout - - # Get recent commit messages for style consistency - git_log_result = subprocess.run( - ["git", "log", "--oneline", "-10"], - capture_output=True, - text=True, - check=True, - ) - recent_commits = git_log_result.stdout - - except subprocess.CalledProcessError: - git_status = "Unable to get git status" - recent_commits = "Unable to get recent commits" - - # Prepare the context for the AI agent - context_prompt = f"""Generate ONE commit message for these changes. - -IMPORTANT: All these changes are part of ONE commit. Find the PRIMARY purpose and create ONE message. - -CONTEXT: -Branch: {branch_name} -Ticket: {ticket_number or 'None'} (will be added automatically if present, do NOT include it in your message) - -STAGED DIFF: -{diff_text} - -GIT STATUS: -{git_status} - -RECENT COMMITS (for style): -{recent_commits} - -Remember: Generate ONLY ONE commit message that captures the main purpose of ALL these changes together. -Do NOT include the ticket number in your response - it will be added automatically if present in branch name.""" - - try: - # Show loading message - click.echo("Generating commit message...") - - # Get AI response as text and parse it - ai_response = agent_ai.query(context_prompt) - - # Parse the response to extract commit message components - # Expected format: type(scope): summary\n\ndetails (optional) - lines = ai_response.strip().split("\n") - first_line = lines[0].strip() - - # Extract type, scope, and summary from first line - # Format: type(scope): summary - if ":" in first_line: - type_scope, summary = first_line.split(":", 1) - summary = summary.strip() - - # Extract type and scope - if "(" in type_scope and ")" in type_scope: - commit_type = type_scope.split("(")[0].strip() - scope = type_scope.split("(")[1].split(")")[0].strip() - else: - commit_type = type_scope.strip() - scope = "general" - else: - # Fallback if format is not as expected - commit_type = "chore" - scope = "general" - summary = first_line - - # Get details if present (everything after first line) - details = "\n".join(lines[1:]).strip() if len(lines) > 1 else "" - details_formatted = "" if not details else "\n\n{}".format(details) - - # Add ticket number to summary if present and not already included - if ticket_number and ticket_number not in summary: - summary = "{} {}".format(ticket_number, summary) - - commit_message = "{}({}): {}{}".format(commit_type, scope, summary, details_formatted).strip() - - # Display the generated commit message - click.echo("\n" + "=" * 60) - click.echo("Generated commit message:") - click.echo("=" * 60) - click.echo(commit_message) - click.echo("=" * 60 + "\n") - - if click.confirm("Do you want to use this commit message?"): - subprocess.run(["git", "commit", "-m", commit_message], check=True) - click.echo("\n✅ Commit message accepted") - else: - manual_message = click.prompt("Enter your commit message") - if ticket_number and ticket_number not in manual_message: - manual_message = "{} {}".format(ticket_number, manual_message) - subprocess.run(["git", "commit", "-m", manual_message], check=True) - click.echo("✅ Manual commit message accepted") - - if push: - click.echo("\nPushing changes to origin/{}...".format(branch_name)) - subprocess.run(["git", "push", "origin", branch_name], check=True) - click.echo("✅ Changes pushed to origin/{}".format(branch_name)) - - if pull_request: - remote_url = get_remote_url() - if remote_url: - # GitHub URL format for creating pull requests - pr_url = remote_url.replace(".git", "") + "/compare/{}?expand=1".format(branch_name) - click.echo("\nOpening pull request URL in browser: {}".format(pr_url)) - webbrowser.open(pr_url) - else: - click.echo("Could not determine remote URL. Cannot open pull request.") - except Exception as e: - # Handle both requests.exceptions.RequestException and botocore.exceptions.NoCredentialsError - msg = str(e) - if "NoCredentialsError" in msg or "credentials" in msg: - click.echo("❌ No AWS credentials found. Please configure your AWS CLI.") - else: - click.echo("❌ Error sending request: {}".format(msg)) +__all__ = ["commit"] diff --git a/cli_tool/commit/__init__.py b/cli_tool/commit/__init__.py new file mode 100644 index 0000000..89aee87 --- /dev/null +++ b/cli_tool/commit/__init__.py @@ -0,0 +1,5 @@ +"""Commit message generation module.""" + +from cli_tool.commit.commands.generate import commit + +__all__ = ["commit"] diff --git a/cli_tool/commit/commands/__init__.py b/cli_tool/commit/commands/__init__.py new file mode 100644 index 0000000..37985e8 --- /dev/null +++ b/cli_tool/commit/commands/__init__.py @@ -0,0 +1,5 @@ +"""Commit CLI commands.""" + +from cli_tool.commit.commands.generate import commit + +__all__ = ["commit"] diff --git a/cli_tool/commit/commands/generate.py b/cli_tool/commit/commands/generate.py new file mode 100644 index 0000000..0098042 --- /dev/null +++ b/cli_tool/commit/commands/generate.py @@ -0,0 +1,98 @@ +"""Commit message generation command.""" + +import subprocess +import webbrowser + +import click + +from cli_tool.commit.core.generator import CommitMessageGenerator +from cli_tool.utils.aws import select_profile +from cli_tool.utils.git_utils import get_branch_name, get_remote_url, get_staged_diff + + +@click.command() +@click.option("--push", "-p", is_flag=True, help="Push the commit to the remote origin.") +@click.option("--pull-request", "-pr", is_flag=True, help="Open a pull request on GitHub.") +@click.option( + "--add", + "-a", + is_flag=True, + help="Add all changes to the staging area before committing.", +) +@click.option( + "--all", + "-A", + is_flag=True, + help="Perform add, commit, push, and open pull request.", +) +@click.pass_context +def commit(ctx, push, pull_request, add, all): + """Generate a commit message based on staged changes with AI.""" + if all: + add = True + push = True + pull_request = True + + if add: + click.echo("Adding all changes to the staging area...") + subprocess.run(["git", "add", "."], check=True) + click.echo("✅ All changes added.") + + # Get profile from context or prompt user to select + ctx.ensure_object(dict) + profile = select_profile(ctx.obj.get("profile")) + + # Get staged diff + diff_text = get_staged_diff() + if not diff_text: + click.echo("No staged changes found.") + return + + branch_name = get_branch_name() + + try: + # Show loading message + click.echo("Generating commit message...") + + # Initialize generator and create commit message + generator = CommitMessageGenerator(profile_name=profile) + commit_message = generator.generate(diff_text, branch_name) + + # Display the generated commit message + click.echo("\n" + "=" * 60) + click.echo("Generated commit message:") + click.echo("=" * 60) + click.echo(commit_message) + click.echo("=" * 60 + "\n") + + if click.confirm("Do you want to use this commit message?"): + subprocess.run(["git", "commit", "-m", commit_message], check=True) + click.echo("\n✅ Commit message accepted") + else: + manual_message = click.prompt("Enter your commit message") + manual_message = generator.add_ticket_to_message(manual_message, branch_name) + subprocess.run(["git", "commit", "-m", manual_message], check=True) + click.echo("✅ Manual commit message accepted") + + if push: + click.echo(f"\nPushing changes to origin/{branch_name}...") + subprocess.run(["git", "push", "origin", branch_name], check=True) + click.echo(f"✅ Changes pushed to origin/{branch_name}") + + if pull_request: + remote_url = get_remote_url() + if remote_url: + # GitHub URL format for creating pull requests + pr_url = remote_url.replace(".git", "") + f"/compare/{branch_name}?expand=1" + click.echo(f"\nOpening pull request URL in browser: {pr_url}") + webbrowser.open(pr_url) + else: + click.echo("Could not determine remote URL. Cannot open pull request.") + + except Exception as e: + # Handle both requests.exceptions.RequestException and botocore.exceptions.NoCredentialsError + msg = str(e) + if "NoCredentialsError" in msg or "credentials" in msg: + click.echo("❌ No AWS credentials found. Please configure your AWS CLI.") + else: + click.echo(f"❌ Error sending request: {msg}") diff --git a/cli_tool/commit/core/__init__.py b/cli_tool/commit/core/__init__.py new file mode 100644 index 0000000..9255e6d --- /dev/null +++ b/cli_tool/commit/core/__init__.py @@ -0,0 +1,5 @@ +"""Commit core business logic.""" + +from cli_tool.commit.core.generator import CommitMessageGenerator + +__all__ = ["CommitMessageGenerator"] diff --git a/cli_tool/commit/core/generator.py b/cli_tool/commit/core/generator.py new file mode 100644 index 0000000..103cc67 --- /dev/null +++ b/cli_tool/commit/core/generator.py @@ -0,0 +1,206 @@ +"""Commit message generation business logic.""" + +import re +import subprocess +from typing import Optional, Tuple + +from cli_tool.agents.base_agent import BaseAgent + + +class CommitMessageGenerator: + """Generates conventional commit messages using AI.""" + + SYSTEM_PROMPT = """You are an AI that generates ONE conventional commit message. + +CRITICAL: Generate ONLY ONE commit message, not multiple. Find the PRIMARY purpose of all changes. + +FORMAT (first line): +type(scope): summary + +VALID TYPES: feat, fix, refactor, chore, docs, test, style, perf + +RULES: +1. First line: type(scope): summary (max 50 chars) +2. Summary explains WHY, not what +3. Optional: blank line + bullet points with details +4. NO markdown, NO multiple messages, NO explanations + +CORRECT EXAMPLES: +refactor(agents): simplify commit message generation + +fix(auth): resolve credential validation error + +- Fixed caching logic +- Added error handling + +INCORRECT (DO NOT DO): +❌ Multiple commit messages listed +❌ refactor(agents): improve X + fix(base): enhance Y + refactor(commit): replace Z + +Generate ONE message capturing the main purpose of ALL changes.""" + + def __init__(self, profile_name: Optional[str] = None): + """Initialize the commit message generator. + + Args: + profile_name: AWS profile to use for AI agent + """ + self.agent = BaseAgent( + name="CommitMessageGenerator", + system_prompt=self.SYSTEM_PROMPT, + profile_name=profile_name, + enable_rich_logging=False, + ) + + def extract_ticket_from_branch(self, branch_name: str) -> Optional[str]: + """Extract ticket number from branch name. + + Args: + branch_name: Git branch name + + Returns: + Ticket number or None + """ + match = re.match(r"(?:feature|fix|chore)/([A-Za-z0-9]+-\d+)", branch_name) + return match.group(1) if match else None + + def get_git_context(self) -> Tuple[str, str]: + """Get git status and recent commits for context. + + Returns: + Tuple of (git_status, recent_commits) + """ + try: + git_status_result = subprocess.run( + ["git", "status", "--porcelain"], + capture_output=True, + text=True, + check=True, + ) + git_status = git_status_result.stdout + + git_log_result = subprocess.run( + ["git", "log", "--oneline", "-10"], + capture_output=True, + text=True, + check=True, + ) + recent_commits = git_log_result.stdout + + return git_status, recent_commits + except subprocess.CalledProcessError: + return "Unable to get git status", "Unable to get recent commits" + + def parse_commit_message(self, ai_response: str) -> Tuple[str, str, str]: + """Parse AI response into commit components. + + Args: + ai_response: Raw AI response + + Returns: + Tuple of (commit_type, scope, summary_with_details) + """ + lines = ai_response.strip().split("\n") + first_line = lines[0].strip() + + # Extract type, scope, and summary from first line + if ":" in first_line: + type_scope, summary = first_line.split(":", 1) + summary = summary.strip() + + # Extract type and scope + if "(" in type_scope and ")" in type_scope: + commit_type = type_scope.split("(")[0].strip() + scope = type_scope.split("(")[1].split(")")[0].strip() + else: + commit_type = type_scope.strip() + scope = "general" + else: + # Fallback if format is not as expected + commit_type = "chore" + scope = "general" + summary = first_line + + # Get details if present (everything after first line) + details = "\n".join(lines[1:]).strip() if len(lines) > 1 else "" + summary_with_details = summary + ("\n\n" + details if details else "") + + return commit_type, scope, summary_with_details + + def generate( + self, + diff_text: str, + branch_name: str, + git_status: Optional[str] = None, + recent_commits: Optional[str] = None, + ) -> str: + """Generate a commit message from staged changes. + + Args: + diff_text: Git diff of staged changes + branch_name: Current branch name + git_status: Git status output (optional) + recent_commits: Recent commit history (optional) + + Returns: + Generated commit message + """ + ticket_number = self.extract_ticket_from_branch(branch_name) + + # Get git context if not provided + if git_status is None or recent_commits is None: + git_status, recent_commits = self.get_git_context() + + context_prompt = f"""Generate ONE commit message for these changes. + +IMPORTANT: All these changes are part of ONE commit. Find the PRIMARY purpose and create ONE message. + +CONTEXT: +Branch: {branch_name} +Ticket: {ticket_number or 'None'} (will be added automatically if present, do NOT include it in your message) + +STAGED DIFF: +{diff_text} + +GIT STATUS: +{git_status} + +RECENT COMMITS (for style): +{recent_commits} + +Remember: Generate ONLY ONE commit message that captures the main purpose of ALL these changes together. +Do NOT include the ticket number in your response - it will be added automatically if present in branch name.""" + + ai_response = self.agent.query(context_prompt) + commit_type, scope, summary_with_details = self.parse_commit_message(ai_response) + + # Add ticket number to summary if present and not already included + summary_parts = summary_with_details.split("\n\n", 1) + summary = summary_parts[0] + details = summary_parts[1] if len(summary_parts) > 1 else "" + + if ticket_number and ticket_number not in summary: + summary = f"{ticket_number} {summary}" + + commit_message = f"{commit_type}({scope}): {summary}" + if details: + commit_message += f"\n\n{details}" + + return commit_message.strip() + + def add_ticket_to_message(self, message: str, branch_name: str) -> str: + """Add ticket number to a manual commit message if not present. + + Args: + message: Commit message + branch_name: Current branch name + + Returns: + Message with ticket number prepended if applicable + """ + ticket_number = self.extract_ticket_from_branch(branch_name) + if ticket_number and ticket_number not in message: + return f"{ticket_number} {message}" + return message From 0e34093f10d4360119d4bea44765c7b1bf31ec6b Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Mon, 2 Mar 2026 00:13:35 -0500 Subject: [PATCH 07/37] refactor(eventbridge): restructure into modular command and core architecture - Move eventbridge logic from monolithic commands file to modular structure - Create eventbridge package with commands, core, and utils submodules - Extract rules management logic into dedicated RulesManager class - Move output formatting logic into separate formatters module - Update cli.py to register eventbridge commands via factory function - Replace direct command import with register_eventbridge_commands() pattern - Maintain backward compatibility with eventbridge command wrapper --- cli_tool/cli.py | 4 +- cli_tool/commands/eventbridge.py | 251 +-------------------- cli_tool/eventbridge/__init__.py | 5 + cli_tool/eventbridge/commands/__init__.py | 38 ++++ cli_tool/eventbridge/commands/list.py | 45 ++++ cli_tool/eventbridge/core/__init__.py | 5 + cli_tool/eventbridge/core/rules_manager.py | 139 ++++++++++++ cli_tool/eventbridge/utils/__init__.py | 5 + cli_tool/eventbridge/utils/formatters.py | 181 +++++++++++++++ 9 files changed, 428 insertions(+), 245 deletions(-) create mode 100644 cli_tool/eventbridge/__init__.py create mode 100644 cli_tool/eventbridge/commands/__init__.py create mode 100644 cli_tool/eventbridge/commands/list.py create mode 100644 cli_tool/eventbridge/core/__init__.py create mode 100644 cli_tool/eventbridge/core/rules_manager.py create mode 100644 cli_tool/eventbridge/utils/__init__.py create mode 100644 cli_tool/eventbridge/utils/formatters.py diff --git a/cli_tool/cli.py b/cli_tool/cli.py index 53edcbd..02044d7 100644 --- a/cli_tool/cli.py +++ b/cli_tool/cli.py @@ -9,10 +9,10 @@ from cli_tool.commands.completion import autocomplete from cli_tool.commands.config import config_command from cli_tool.commands.dynamodb import dynamodb -from cli_tool.commands.eventbridge import eventbridge from cli_tool.commands.ssm import ssm from cli_tool.commands.upgrade import upgrade from cli_tool.commit import commit +from cli_tool.eventbridge import register_eventbridge_commands console = Console() @@ -108,7 +108,7 @@ def cli(ctx, profile): cli.add_command(code_reviewer) cli.add_command(config_command) cli.add_command(dynamodb) -cli.add_command(eventbridge) +cli.add_command(register_eventbridge_commands()) cli.add_command(ssm) diff --git a/cli_tool/commands/eventbridge.py b/cli_tool/commands/eventbridge.py index ef493ec..1d21b2b 100644 --- a/cli_tool/commands/eventbridge.py +++ b/cli_tool/commands/eventbridge.py @@ -1,247 +1,12 @@ -import click -from rich.console import Console -from rich.table import Table +"""EventBridge command wrapper. -console = Console() +This is a thin wrapper that imports from the main eventbridge module. +All logic is in cli_tool/eventbridge/. +""" +from cli_tool.eventbridge import register_eventbridge_commands -@click.command() -@click.option( - "--env", - "-e", - help="Filter by environment (e.g., dev, staging, prod)", - required=False, -) -@click.option("--region", "-r", default="us-east-1", help="AWS region (default: us-east-1)") -@click.option( - "--status", - "-s", - type=click.Choice(["ENABLED", "DISABLED", "ALL"], case_sensitive=False), - default="ALL", - help="Filter by rule status", -) -@click.option( - "--output", - "-o", - type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - help="Output format (default: table)", -) -@click.pass_context -def eventbridge(ctx, env, region, status, output): - """Check EventBridge scheduled rules status by environment.""" - from botocore.exceptions import ClientError, NoCredentialsError +# For backward compatibility +eventbridge = register_eventbridge_commands() - from cli_tool.utils.aws import create_aws_client, select_profile - - # Get profile from context or prompt user to select - profile = select_profile(ctx.obj.get("profile")) - - try: - # Create EventBridge client - events_client = create_aws_client("events", profile_name=profile, region_name=region) - - console.print(f"\n[blue]Fetching EventBridge rules from {region}...[/blue]\n") - - # List all rules - paginator = events_client.get_paginator("list_rules") - all_rules = [] - - for page in paginator.paginate(): - all_rules.extend(page["Rules"]) - - if not all_rules: - console.print("[yellow]No EventBridge rules found[/yellow]") - return - - # Filter rules - filtered_rules = [] - for rule in all_rules: - # Filter by status - if status != "ALL" and rule["State"] != status: - continue - - # Get rule details including targets and tags - rule_name = rule["Name"] - - # Get targets - try: - targets_response = events_client.list_targets_by_rule(Rule=rule_name) - targets = targets_response.get("Targets", []) - except ClientError: - targets = [] - - # Get tags - try: - rule_arn = rule["Arn"] - tags_response = events_client.list_tags_for_resource(ResourceARN=rule_arn) - tags = {tag["Key"]: tag["Value"] for tag in tags_response.get("Tags", [])} - except ClientError: - tags = {} - - # Filter by environment if specified - if env: - env_match = False - - # Check if Env tag matches - env_from_tag = tags.get("Env", tags.get("Environment", "")).lower() - if env_from_tag == env.lower(): - env_match = True - - # If no tag match, extract environment from target names - if not env_match: - for target in targets: - target_arn = target.get("Arn", "") - - # Check simple patterns first - if f"service-{env}-" in target_arn.lower() or f"-{env}-lambda" in target_arn.lower(): - env_match = True - break - - # Extract from Lambda function name pattern: *-env-* - if ":function:" in target_arn: - func_name = target_arn.split(":function:")[-1].split(":")[0] - # Look for pattern like service-dev-lambda or processor-prod-handler - parts = func_name.split("-") - for part in parts: - # Check if this part matches the environment - if part.lower() == env.lower(): - env_match = True - break - if env_match: - break - - if not env_match: - continue - - # Add to filtered list - filtered_rules.append({"rule": rule, "targets": targets, "tags": tags}) - - if not filtered_rules: - filter_msg = f" for environment '{env}'" if env else "" - console.print(f"[yellow]No rules found{filter_msg}[/yellow]") - return - - # Output as JSON if requested - if output == "json": - import json - - output_data = [] - for item in filtered_rules: - rule = item["rule"] - targets = item["targets"] - tags = item["tags"] - - output_data.append( - { - "name": rule["Name"], - "arn": rule["Arn"], - "state": rule["State"], - "schedule": rule.get("ScheduleExpression", "N/A"), - "description": rule.get("Description", ""), - "targets": [ - { - "id": t.get("Id"), - "arn": t.get("Arn"), - "input": t.get("Input", t.get("InputPath", "")), - } - for t in targets - ], - "tags": tags, - } - ) - - console.print(json.dumps(output_data, indent=2)) - return - - # Display results in a table - table = Table( - title=f"EventBridge Scheduled Rules{' - ' + env.upper() if env else ''}", - show_lines=True, - ) - table.add_column("Rule Name", style="cyan", no_wrap=False, width=50) - table.add_column("Status", style="magenta", width=12) - table.add_column("Schedule", style="green", no_wrap=False, width=30) - table.add_column("Targets", style="yellow", no_wrap=False, width=40) - table.add_column("Env", style="blue", width=10) - - for item in filtered_rules: - rule = item["rule"] - targets = item["targets"] - tags = item["tags"] - - rule_name = rule["Name"] - state = rule["State"] - schedule = rule.get("ScheduleExpression", "N/A") - - # Format status with emoji - status_display = "✅ ENABLED" if state == "ENABLED" else "❌ DISABLED" - - # Format targets - target_list = [] - for target in targets: - target_arn = target.get("Arn", "") - # Extract Lambda function name from ARN - if ":function:" in target_arn: - func_name = target_arn.split(":function:")[-1].split(":")[0] # Remove version/alias - target_list.append(func_name) - elif target_arn: - # For other services, show the last part of ARN - target_list.append(target_arn.split(":")[-1][:30]) - - targets_display = "\n".join(target_list[:2]) # Show max 2 targets - if len(target_list) > 2: - targets_display += f"\n+{len(target_list) - 2} more" - elif not target_list: - targets_display = "No targets" - - # Get environment from tags - env_tag = tags.get("Env", tags.get("Environment", "")) - - # If no env tag, try to extract from target names - if not env_tag and targets: - for target in targets: - target_arn = target.get("Arn", "") - # Extract from Lambda function name pattern: *-env-* - if ":function:" in target_arn: - func_name = target_arn.split(":function:")[-1].split(":")[0] - # Look for pattern like service-dev-lambda or processor-prod-handler - parts = func_name.split("-") - for i, part in enumerate(parts): - # Check if this part looks like an environment (common env names) - if part.lower() in [ - "dev", - "develop", - "development", - "staging", - "stage", - "stg", - "prod", - "production", - "test", - "qa", - "uat", - "demo", - ]: - env_tag = part - break - if env_tag: - break - - table.add_row(rule_name, status_display, schedule, targets_display, env_tag or "N/A") - - console.print(table) - console.print(f"\n[green]Total rules: {len(filtered_rules)}[/green]") - - # Summary by status - enabled_count = sum(1 for item in filtered_rules if item["rule"]["State"] == "ENABLED") - disabled_count = len(filtered_rules) - enabled_count - console.print(f"[green]Enabled: {enabled_count}[/green] | [red]Disabled: {disabled_count}[/red]\n") - - except NoCredentialsError: - console.print("[red]Error: AWS credentials not found[/red]") - console.print("Please configure your AWS credentials or specify a profile") - except ClientError as e: - console.print(f"[red]AWS Error: {e.response['Error']['Message']}[/red]") - except Exception as e: - console.print(f"[red]Error: {str(e)}[/red]") +__all__ = ["eventbridge"] diff --git a/cli_tool/eventbridge/__init__.py b/cli_tool/eventbridge/__init__.py new file mode 100644 index 0000000..bcd82f4 --- /dev/null +++ b/cli_tool/eventbridge/__init__.py @@ -0,0 +1,5 @@ +"""EventBridge rules management.""" + +from cli_tool.eventbridge.commands import register_eventbridge_commands + +__all__ = ["register_eventbridge_commands"] diff --git a/cli_tool/eventbridge/commands/__init__.py b/cli_tool/eventbridge/commands/__init__.py new file mode 100644 index 0000000..b1eaa6f --- /dev/null +++ b/cli_tool/eventbridge/commands/__init__.py @@ -0,0 +1,38 @@ +"""EventBridge CLI commands.""" + +import click + +from cli_tool.eventbridge.commands.list import list_rules + + +def register_eventbridge_commands(): + """Register EventBridge commands.""" + + @click.command("eventbridge") + @click.option( + "--env", + "-e", + help="Filter by environment (e.g., dev, staging, prod)", + required=False, + ) + @click.option("--region", "-r", default="us-east-1", help="AWS region (default: us-east-1)") + @click.option( + "--status", + "-s", + type=click.Choice(["ENABLED", "DISABLED", "ALL"], case_sensitive=False), + default="ALL", + help="Filter by rule status", + ) + @click.option( + "--output", + "-o", + type=click.Choice(["table", "json"], case_sensitive=False), + default="table", + help="Output format (default: table)", + ) + @click.pass_context + def eventbridge_cmd(ctx, env, region, status, output): + """Check EventBridge scheduled rules status by environment.""" + list_rules(ctx, env, region, status, output) + + return eventbridge_cmd diff --git a/cli_tool/eventbridge/commands/list.py b/cli_tool/eventbridge/commands/list.py new file mode 100644 index 0000000..e787f97 --- /dev/null +++ b/cli_tool/eventbridge/commands/list.py @@ -0,0 +1,45 @@ +"""List EventBridge rules command.""" + +from rich.console import Console + +from cli_tool.eventbridge.core.rules_manager import RulesManager +from cli_tool.eventbridge.utils.formatters import format_json_output, format_table_output +from cli_tool.utils.aws import select_profile + +console = Console() + + +def list_rules(ctx, env, region, status, output): + """List EventBridge rules with filtering and formatting.""" + from botocore.exceptions import ClientError, NoCredentialsError + + # Get profile from context or prompt user to select + profile = select_profile(ctx.obj.get("profile")) + + try: + # Create rules manager + manager = RulesManager(profile, region) + + console.print(f"\n[blue]Fetching EventBridge rules from {region}...[/blue]\n") + + # Fetch and filter rules + filtered_rules = manager.get_filtered_rules(env=env, status=status) + + if not filtered_rules: + filter_msg = f" for environment '{env}'" if env else "" + console.print(f"[yellow]No rules found{filter_msg}[/yellow]") + return + + # Output based on format + if output == "json": + format_json_output(filtered_rules) + else: + format_table_output(filtered_rules, env) + + except NoCredentialsError: + console.print("[red]Error: AWS credentials not found[/red]") + console.print("Please configure your AWS credentials or specify a profile") + except ClientError as e: + console.print(f"[red]AWS Error: {e.response['Error']['Message']}[/red]") + except Exception as e: + console.print(f"[red]Error: {str(e)}[/red]") diff --git a/cli_tool/eventbridge/core/__init__.py b/cli_tool/eventbridge/core/__init__.py new file mode 100644 index 0000000..de78025 --- /dev/null +++ b/cli_tool/eventbridge/core/__init__.py @@ -0,0 +1,5 @@ +"""EventBridge core business logic.""" + +from cli_tool.eventbridge.core.rules_manager import RulesManager + +__all__ = ["RulesManager"] diff --git a/cli_tool/eventbridge/core/rules_manager.py b/cli_tool/eventbridge/core/rules_manager.py new file mode 100644 index 0000000..a160cf9 --- /dev/null +++ b/cli_tool/eventbridge/core/rules_manager.py @@ -0,0 +1,139 @@ +"""EventBridge rules management logic.""" + +from typing import Dict, List, Optional + +from botocore.exceptions import ClientError + +from cli_tool.utils.aws import create_aws_client + + +class RulesManager: + """Manages EventBridge rules operations.""" + + def __init__(self, profile: str, region: str = "us-east-1"): + """Initialize rules manager. + + Args: + profile: AWS profile name + region: AWS region + """ + self.profile = profile + self.region = region + self.client = create_aws_client("events", profile_name=profile, region_name=region) + + def get_all_rules(self) -> List[Dict]: + """Fetch all EventBridge rules. + + Returns: + List of rule dictionaries + """ + paginator = self.client.get_paginator("list_rules") + all_rules = [] + + for page in paginator.paginate(): + all_rules.extend(page["Rules"]) + + return all_rules + + def get_rule_targets(self, rule_name: str) -> List[Dict]: + """Get targets for a specific rule. + + Args: + rule_name: Name of the rule + + Returns: + List of target dictionaries + """ + try: + response = self.client.list_targets_by_rule(Rule=rule_name) + return response.get("Targets", []) + except ClientError: + return [] + + def get_rule_tags(self, rule_arn: str) -> Dict[str, str]: + """Get tags for a specific rule. + + Args: + rule_arn: ARN of the rule + + Returns: + Dictionary of tag key-value pairs + """ + try: + response = self.client.list_tags_for_resource(ResourceARN=rule_arn) + return {tag["Key"]: tag["Value"] for tag in response.get("Tags", [])} + except ClientError: + return {} + + def get_filtered_rules(self, env: Optional[str] = None, status: str = "ALL") -> List[Dict]: + """Get rules with filtering applied. + + Args: + env: Environment filter (e.g., dev, staging, prod) + status: Status filter (ENABLED, DISABLED, ALL) + + Returns: + List of filtered rule dictionaries with targets and tags + """ + all_rules = self.get_all_rules() + + if not all_rules: + return [] + + filtered_rules = [] + + for rule in all_rules: + # Filter by status + if status != "ALL" and rule["State"] != status: + continue + + # Get rule details + rule_name = rule["Name"] + rule_arn = rule["Arn"] + + targets = self.get_rule_targets(rule_name) + tags = self.get_rule_tags(rule_arn) + + # Filter by environment if specified + if env and not self._matches_environment(env, targets, tags): + continue + + # Add to filtered list + filtered_rules.append({"rule": rule, "targets": targets, "tags": tags}) + + return filtered_rules + + def _matches_environment(self, env: str, targets: List[Dict], tags: Dict[str, str]) -> bool: + """Check if rule matches the specified environment. + + Args: + env: Environment to match + targets: List of rule targets + tags: Rule tags + + Returns: + True if rule matches environment + """ + # Check environment tag + env_from_tag = tags.get("Env", tags.get("Environment", "")).lower() + if env_from_tag == env.lower(): + return True + + # Check target ARNs for environment patterns + for target in targets: + target_arn = target.get("Arn", "") + + # Check simple patterns + if f"service-{env}-" in target_arn.lower() or f"-{env}-lambda" in target_arn.lower(): + return True + + # Extract from Lambda function name pattern + if ":function:" in target_arn: + func_name = target_arn.split(":function:")[-1].split(":")[0] + parts = func_name.split("-") + + for part in parts: + if part.lower() == env.lower(): + return True + + return False diff --git a/cli_tool/eventbridge/utils/__init__.py b/cli_tool/eventbridge/utils/__init__.py new file mode 100644 index 0000000..1c549dc --- /dev/null +++ b/cli_tool/eventbridge/utils/__init__.py @@ -0,0 +1,5 @@ +"""EventBridge utilities.""" + +from cli_tool.eventbridge.utils.formatters import format_json_output, format_table_output + +__all__ = ["format_json_output", "format_table_output"] diff --git a/cli_tool/eventbridge/utils/formatters.py b/cli_tool/eventbridge/utils/formatters.py new file mode 100644 index 0000000..c280f69 --- /dev/null +++ b/cli_tool/eventbridge/utils/formatters.py @@ -0,0 +1,181 @@ +"""Output formatting utilities for EventBridge rules.""" + +import json +from typing import Dict, List + +from rich.console import Console +from rich.table import Table + +console = Console() + +# Common environment names for extraction +COMMON_ENVS = [ + "dev", + "develop", + "development", + "staging", + "stage", + "stg", + "prod", + "production", + "test", + "qa", + "uat", + "demo", +] + + +def format_json_output(filtered_rules: List[Dict]): + """Format rules as JSON output. + + Args: + filtered_rules: List of filtered rule dictionaries + """ + output_data = [] + + for item in filtered_rules: + rule = item["rule"] + targets = item["targets"] + tags = item["tags"] + + output_data.append( + { + "name": rule["Name"], + "arn": rule["Arn"], + "state": rule["State"], + "schedule": rule.get("ScheduleExpression", "N/A"), + "description": rule.get("Description", ""), + "targets": [ + { + "id": t.get("Id"), + "arn": t.get("Arn"), + "input": t.get("Input", t.get("InputPath", "")), + } + for t in targets + ], + "tags": tags, + } + ) + + console.print(json.dumps(output_data, indent=2)) + + +def format_table_output(filtered_rules: List[Dict], env: str = None): + """Format rules as table output. + + Args: + filtered_rules: List of filtered rule dictionaries + env: Environment filter (for title) + """ + table = Table( + title=f"EventBridge Scheduled Rules{' - ' + env.upper() if env else ''}", + show_lines=True, + ) + table.add_column("Rule Name", style="cyan", no_wrap=False, width=50) + table.add_column("Status", style="magenta", width=12) + table.add_column("Schedule", style="green", no_wrap=False, width=30) + table.add_column("Targets", style="yellow", no_wrap=False, width=40) + table.add_column("Env", style="blue", width=10) + + for item in filtered_rules: + rule = item["rule"] + targets = item["targets"] + tags = item["tags"] + + rule_name = rule["Name"] + state = rule["State"] + schedule = rule.get("ScheduleExpression", "N/A") + + # Format status with emoji + status_display = "✅ ENABLED" if state == "ENABLED" else "❌ DISABLED" + + # Format targets + targets_display = _format_targets(targets) + + # Get environment + env_tag = _extract_environment(targets, tags) + + table.add_row(rule_name, status_display, schedule, targets_display, env_tag or "N/A") + + console.print(table) + + # Print summary + _print_summary(filtered_rules) + + +def _format_targets(targets: List[Dict]) -> str: + """Format target list for display. + + Args: + targets: List of target dictionaries + + Returns: + Formatted target string + """ + target_list = [] + + for target in targets: + target_arn = target.get("Arn", "") + + # Extract Lambda function name from ARN + if ":function:" in target_arn: + func_name = target_arn.split(":function:")[-1].split(":")[0] + target_list.append(func_name) + elif target_arn: + # For other services, show the last part of ARN + target_list.append(target_arn.split(":")[-1][:30]) + + if not target_list: + return "No targets" + + # Show max 2 targets + result = "\n".join(target_list[:2]) + if len(target_list) > 2: + result += f"\n+{len(target_list) - 2} more" + + return result + + +def _extract_environment(targets: List[Dict], tags: Dict[str, str]) -> str: + """Extract environment from tags or target names. + + Args: + targets: List of target dictionaries + tags: Rule tags + + Returns: + Environment name or empty string + """ + # Check tags first + env_tag = tags.get("Env", tags.get("Environment", "")) + if env_tag: + return env_tag + + # Try to extract from target names + for target in targets: + target_arn = target.get("Arn", "") + + # Extract from Lambda function name pattern + if ":function:" in target_arn: + func_name = target_arn.split(":function:")[-1].split(":")[0] + parts = func_name.split("-") + + for part in parts: + if part.lower() in COMMON_ENVS: + return part + + return "" + + +def _print_summary(filtered_rules: List[Dict]): + """Print summary statistics. + + Args: + filtered_rules: List of filtered rule dictionaries + """ + total = len(filtered_rules) + enabled = sum(1 for item in filtered_rules if item["rule"]["State"] == "ENABLED") + disabled = total - enabled + + console.print(f"\n[green]Total rules: {total}[/green]") + console.print(f"[green]Enabled: {enabled}[/green] | [red]Disabled: {disabled}[/red]\n") From 432b24ef274be8903dcb4455bbb16fa8c061a02b Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Mon, 2 Mar 2026 00:23:23 -0500 Subject: [PATCH 08/37] refactor(config): restructure into modular command and core architecture - Move config command logic into dedicated config_cmd module with subcommands - Create modular structure: config_cmd/commands/ for individual subcommands (show, export, import, migrate, reset, sections, set, path) - Extract shared descriptions and utilities into config_cmd/core/descriptions.py - Update cli.py to import and register config commands via register_config_commands() - Maintain backward compatibility by keeping thin wrapper in commands/config.py - Align with existing modular architecture pattern used in eventbridge, ssm, and other commands --- cli_tool/cli.py | 4 +- cli_tool/commands/config.py | 227 +-------------------- cli_tool/config_cmd/__init__.py | 5 + cli_tool/config_cmd/commands/__init__.py | 33 +++ cli_tool/config_cmd/commands/export.py | 55 +++++ cli_tool/config_cmd/commands/import_cmd.py | 43 ++++ cli_tool/config_cmd/commands/migrate.py | 32 +++ cli_tool/config_cmd/commands/path.py | 15 ++ cli_tool/config_cmd/commands/reset.py | 32 +++ cli_tool/config_cmd/commands/sections.py | 26 +++ cli_tool/config_cmd/commands/set.py | 38 ++++ cli_tool/config_cmd/commands/show.py | 32 +++ cli_tool/config_cmd/core/__init__.py | 5 + cli_tool/config_cmd/core/descriptions.py | 10 + 14 files changed, 336 insertions(+), 221 deletions(-) create mode 100644 cli_tool/config_cmd/__init__.py create mode 100644 cli_tool/config_cmd/commands/__init__.py create mode 100644 cli_tool/config_cmd/commands/export.py create mode 100644 cli_tool/config_cmd/commands/import_cmd.py create mode 100644 cli_tool/config_cmd/commands/migrate.py create mode 100644 cli_tool/config_cmd/commands/path.py create mode 100644 cli_tool/config_cmd/commands/reset.py create mode 100644 cli_tool/config_cmd/commands/sections.py create mode 100644 cli_tool/config_cmd/commands/set.py create mode 100644 cli_tool/config_cmd/commands/show.py create mode 100644 cli_tool/config_cmd/core/__init__.py create mode 100644 cli_tool/config_cmd/core/descriptions.py diff --git a/cli_tool/cli.py b/cli_tool/cli.py index 02044d7..c1a94f8 100644 --- a/cli_tool/cli.py +++ b/cli_tool/cli.py @@ -7,11 +7,11 @@ from cli_tool.commands.aws_login import aws_login from cli_tool.commands.code_reviewer import code_reviewer from cli_tool.commands.completion import autocomplete -from cli_tool.commands.config import config_command from cli_tool.commands.dynamodb import dynamodb from cli_tool.commands.ssm import ssm from cli_tool.commands.upgrade import upgrade from cli_tool.commit import commit +from cli_tool.config_cmd import register_config_commands from cli_tool.eventbridge import register_eventbridge_commands console = Console() @@ -106,7 +106,7 @@ def cli(ctx, profile): cli.add_command(codeartifact_login) cli.add_command(autocomplete) cli.add_command(code_reviewer) -cli.add_command(config_command) +cli.add_command(register_config_commands()) cli.add_command(dynamodb) cli.add_command(register_eventbridge_commands()) cli.add_command(ssm) diff --git a/cli_tool/commands/config.py b/cli_tool/commands/config.py index b347ccb..822e1ca 100644 --- a/cli_tool/commands/config.py +++ b/cli_tool/commands/config.py @@ -1,223 +1,12 @@ -"""Configuration management commands.""" +"""Configuration command wrapper. -import json +This is a thin wrapper that imports from the main config_cmd module. +All logic is in cli_tool/config_cmd/. +""" -import click -from rich.console import Console -from rich.syntax import Syntax -from rich.table import Table +from cli_tool.config_cmd import register_config_commands -from cli_tool.utils.config_manager import ( - export_config, - get_config_path, - import_config, - list_config_sections, - load_config, - migrate_legacy_configs, - reset_config, - set_config_value, -) +# For backward compatibility +config_command = register_config_commands() -console = Console() - - -@click.group(name="config") -def config_command(): - """Manage Devo CLI configuration.""" - pass - - -@config_command.command(name="show") -@click.option("--section", "-s", help="Show only specific section (e.g., ssm, dynamodb)") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -def show_config(section, as_json): - """Show current configuration.""" - config = load_config() - - if section: - if section not in config: - console.print(f"[red]✗ Section '{section}' not found[/red]") - console.print(f"Available sections: {', '.join(list_config_sections())}") - return - config = {section: config[section]} - - if as_json: - console.print(json.dumps(config, indent=2)) - else: - syntax = Syntax(json.dumps(config, indent=2), "json", theme="monokai", line_numbers=True) - console.print(syntax) - - -@config_command.command(name="path") -def show_path(): - """Show configuration file path.""" - config_path = get_config_path() - console.print(f"[cyan]{config_path}[/cyan]") - - -@config_command.command(name="sections") -def list_sections(): - """List all configuration sections.""" - sections = list_config_sections() - - table = Table(title="Configuration Sections") - table.add_column("Section", style="cyan") - table.add_column("Description", style="dim") - - descriptions = { - "bedrock": "AWS Bedrock AI model settings", - "github": "GitHub repository configuration", - "codeartifact": "AWS CodeArtifact settings", - "version_check": "Version check preferences", - "ssm": "SSM connection configurations", - "dynamodb": "DynamoDB export templates", - } - - for section in sections: - desc = descriptions.get(section, "") - table.add_row(section, desc) - - console.print(table) - - -@config_command.command(name="export") -@click.option("--section", "-s", multiple=True, help="Export specific sections (can be used multiple times)") -@click.option("--output", "-o", help="Output file path (default: stdout)") -def export_command(section, output): - """Export configuration (full or partial). - - Examples: - - # Export full config to stdout - devo config export - - # Export only SSM config - devo config export -s ssm - - # Export SSM and DynamoDB to file - devo config export -s ssm -s dynamodb -o backup.json - """ - sections = list(section) if section else None - - try: - exported = export_config(sections=sections, output_path=output) - - if output: - console.print(f"[green]✓ Configuration exported to {output}[/green]") - else: - console.print(json.dumps(exported, indent=2)) - except Exception as e: - console.print(f"[red]✗ Export failed: {e}[/red]") - - -@config_command.command(name="import") -@click.argument("input_file", type=click.Path(exists=True)) -@click.option("--section", "-s", multiple=True, help="Import specific sections only") -@click.option("--replace", is_flag=True, help="Replace sections instead of merging") -def import_command(input_file, section, replace): - """Import configuration from file. - - Examples: - - # Import full config (merge with existing) - devo config import backup.json - - # Import only SSM section - devo config import backup.json -s ssm - - # Replace SSM section completely - devo config import backup.json -s ssm --replace - """ - sections = list(section) if section else None - merge = not replace - - try: - import_config(input_file, sections=sections, merge=merge) - - action = "merged" if merge else "replaced" - if sections: - console.print(f"[green]✓ Sections {', '.join(sections)} {action} from {input_file}[/green]") - else: - console.print(f"[green]✓ Configuration {action} from {input_file}[/green]") - except FileNotFoundError as e: - console.print(f"[red]✗ {e}[/red]") - except Exception as e: - console.print(f"[red]✗ Import failed: {e}[/red]") - - -@config_command.command(name="migrate") -@click.option("--no-backup", is_flag=True, help="Don't backup legacy files") -def migrate_command(no_backup): - """Migrate legacy config files to consolidated format. - - This command consolidates: - - ~/.devo/ssm-config.json - - ~/.devo/dynamodb/export_templates.json - - Into a single ~/.devo/config.json file. - """ - console.print("[cyan]Migrating legacy configuration files...[/cyan]\n") - - status = migrate_legacy_configs(backup=not no_backup) - - if status["already_migrated"]: - return - - if status["ssm"] or status["dynamodb"]: - console.print("\n[green]✓ Migration completed successfully[/green]") - else: - console.print("\n[yellow]No legacy config files found to migrate[/yellow]") - - -@config_command.command(name="reset") -@click.option("--section", "-s", help="Reset only specific section") -@click.confirmation_option(prompt="Are you sure you want to reset configuration?") -def reset_command(section): - """Reset configuration to defaults. - - WARNING: This will delete your current configuration! - """ - if section: - # Reset specific section - from cli_tool.utils.config_manager import get_default_config - - default_value = get_default_config().get(section) - - if default_value is None: - console.print(f"[red]✗ Unknown section: {section}[/red]") - return - - set_config_value(section, default_value) - console.print(f"[green]✓ Section '{section}' reset to defaults[/green]") - else: - # Reset full config - reset_config() - console.print("[green]✓ Configuration reset to defaults[/green]") - - -@config_command.command(name="set") -@click.argument("key") -@click.argument("value") -def set_command(key, value): - """Set a configuration value. - - Examples: - - # Set Bedrock model ID - devo config set bedrock.model_id "new-model-id" - - # Enable version check - devo config set version_check.enabled true - """ - # Try to parse value as JSON (for booleans, numbers, objects) - try: - parsed_value = json.loads(value) - except json.JSONDecodeError: - # Keep as string if not valid JSON - parsed_value = value - - try: - set_config_value(key, parsed_value) - console.print(f"[green]✓ Set {key} = {parsed_value}[/green]") - except Exception as e: - console.print(f"[red]✗ Failed to set value: {e}[/red]") +__all__ = ["config_command"] diff --git a/cli_tool/config_cmd/__init__.py b/cli_tool/config_cmd/__init__.py new file mode 100644 index 0000000..98ed0d5 --- /dev/null +++ b/cli_tool/config_cmd/__init__.py @@ -0,0 +1,5 @@ +"""Configuration management.""" + +from cli_tool.config_cmd.commands import register_config_commands + +__all__ = ["register_config_commands"] diff --git a/cli_tool/config_cmd/commands/__init__.py b/cli_tool/config_cmd/commands/__init__.py new file mode 100644 index 0000000..3810e69 --- /dev/null +++ b/cli_tool/config_cmd/commands/__init__.py @@ -0,0 +1,33 @@ +"""Configuration CLI commands.""" + +import click + +from cli_tool.config_cmd.commands.export import export_command +from cli_tool.config_cmd.commands.import_cmd import import_command +from cli_tool.config_cmd.commands.migrate import migrate_command +from cli_tool.config_cmd.commands.path import show_path +from cli_tool.config_cmd.commands.reset import reset_command +from cli_tool.config_cmd.commands.sections import list_sections +from cli_tool.config_cmd.commands.set import set_command +from cli_tool.config_cmd.commands.show import show_config + + +def register_config_commands(): + """Register configuration commands.""" + + @click.group(name="config") + def config_group(): + """Manage Devo CLI configuration.""" + pass + + # Register all subcommands + config_group.add_command(show_config, "show") + config_group.add_command(show_path, "path") + config_group.add_command(list_sections, "sections") + config_group.add_command(export_command, "export") + config_group.add_command(import_command, "import") + config_group.add_command(migrate_command, "migrate") + config_group.add_command(reset_command, "reset") + config_group.add_command(set_command, "set") + + return config_group diff --git a/cli_tool/config_cmd/commands/export.py b/cli_tool/config_cmd/commands/export.py new file mode 100644 index 0000000..b0831db --- /dev/null +++ b/cli_tool/config_cmd/commands/export.py @@ -0,0 +1,55 @@ +"""Export configuration command.""" + +import json +from datetime import datetime + +import click +from rich.console import Console + +from cli_tool.utils.config_manager import export_config + +console = Console() + + +@click.command() +@click.option("--section", "-s", multiple=True, help="Export specific sections (can be used multiple times)") +@click.option("--output", "-o", help="Output file path (default: devo-config-backup-YYYYMMDD-HHMMSS.json)") +@click.option("--stdout", is_flag=True, help="Print to stdout instead of saving to file") +def export_command(section, output, stdout): + """Export configuration (full or partial). + + Examples: + + # Export to default timestamped file + devo config export + + # Export to stdout + devo config export --stdout + + # Export only SSM config to custom file + devo config export -s ssm -o ssm-backup.json + + # Export SSM and DynamoDB to stdout + devo config export -s ssm -s dynamodb --stdout + """ + sections = list(section) if section else None + + # Determine output path + if stdout: + output_path = None + elif not output: + # Generate default filename with timestamp + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + output_path = f"devo-config-backup-{timestamp}.json" + else: + output_path = output + + try: + exported = export_config(sections=sections, output_path=output_path) + + if output_path: + console.print(f"[green]✓ Configuration exported to {output_path}[/green]") + else: + console.print(json.dumps(exported, indent=2)) + except Exception as e: + console.print(f"[red]✗ Export failed: {e}[/red]") diff --git a/cli_tool/config_cmd/commands/import_cmd.py b/cli_tool/config_cmd/commands/import_cmd.py new file mode 100644 index 0000000..e9a8285 --- /dev/null +++ b/cli_tool/config_cmd/commands/import_cmd.py @@ -0,0 +1,43 @@ +"""Import configuration command.""" + +import click +from rich.console import Console + +from cli_tool.utils.config_manager import import_config + +console = Console() + + +@click.command() +@click.argument("input_file", type=click.Path(exists=True)) +@click.option("--section", "-s", multiple=True, help="Import specific sections only") +@click.option("--replace", is_flag=True, help="Replace sections instead of merging") +def import_command(input_file, section, replace): + """Import configuration from file. + + Examples: + + # Import full config (merge with existing) + devo config import backup.json + + # Import only SSM section + devo config import backup.json -s ssm + + # Replace SSM section completely + devo config import backup.json -s ssm --replace + """ + sections = list(section) if section else None + merge = not replace + + try: + import_config(input_file, sections=sections, merge=merge) + + action = "merged" if merge else "replaced" + if sections: + console.print(f"[green]✓ Sections {', '.join(sections)} {action} from {input_file}[/green]") + else: + console.print(f"[green]✓ Configuration {action} from {input_file}[/green]") + except FileNotFoundError as e: + console.print(f"[red]✗ {e}[/red]") + except Exception as e: + console.print(f"[red]✗ Import failed: {e}[/red]") diff --git a/cli_tool/config_cmd/commands/migrate.py b/cli_tool/config_cmd/commands/migrate.py new file mode 100644 index 0000000..0d7b62e --- /dev/null +++ b/cli_tool/config_cmd/commands/migrate.py @@ -0,0 +1,32 @@ +"""Migrate legacy configuration command.""" + +import click +from rich.console import Console + +from cli_tool.utils.config_manager import migrate_legacy_configs + +console = Console() + + +@click.command() +@click.option("--no-backup", is_flag=True, help="Don't backup legacy files") +def migrate_command(no_backup): + """Migrate legacy config files to consolidated format. + + This command consolidates: + - ~/.devo/ssm-config.json + - ~/.devo/dynamodb/export_templates.json + + Into a single ~/.devo/config.json file. + """ + console.print("[cyan]Migrating legacy configuration files...[/cyan]\n") + + status = migrate_legacy_configs(backup=not no_backup) + + if status["already_migrated"]: + return + + if status["ssm"] or status["dynamodb"]: + console.print("\n[green]✓ Migration completed successfully[/green]") + else: + console.print("\n[yellow]No legacy config files found to migrate[/yellow]") diff --git a/cli_tool/config_cmd/commands/path.py b/cli_tool/config_cmd/commands/path.py new file mode 100644 index 0000000..a44d3f4 --- /dev/null +++ b/cli_tool/config_cmd/commands/path.py @@ -0,0 +1,15 @@ +"""Show configuration path command.""" + +import click +from rich.console import Console + +from cli_tool.utils.config_manager import get_config_path + +console = Console() + + +@click.command() +def show_path(): + """Show configuration file path.""" + config_path = get_config_path() + console.print(f"[cyan]{config_path}[/cyan]") diff --git a/cli_tool/config_cmd/commands/reset.py b/cli_tool/config_cmd/commands/reset.py new file mode 100644 index 0000000..5034d07 --- /dev/null +++ b/cli_tool/config_cmd/commands/reset.py @@ -0,0 +1,32 @@ +"""Reset configuration command.""" + +import click +from rich.console import Console + +from cli_tool.utils.config_manager import get_default_config, reset_config, set_config_value + +console = Console() + + +@click.command() +@click.option("--section", "-s", help="Reset only specific section") +@click.confirmation_option(prompt="Are you sure you want to reset configuration?") +def reset_command(section): + """Reset configuration to defaults. + + WARNING: This will delete your current configuration! + """ + if section: + # Reset specific section + default_value = get_default_config().get(section) + + if default_value is None: + console.print(f"[red]✗ Unknown section: {section}[/red]") + return + + set_config_value(section, default_value) + console.print(f"[green]✓ Section '{section}' reset to defaults[/green]") + else: + # Reset full config + reset_config() + console.print("[green]✓ Configuration reset to defaults[/green]") diff --git a/cli_tool/config_cmd/commands/sections.py b/cli_tool/config_cmd/commands/sections.py new file mode 100644 index 0000000..f3dc98d --- /dev/null +++ b/cli_tool/config_cmd/commands/sections.py @@ -0,0 +1,26 @@ +"""List configuration sections command.""" + +import click +from rich.console import Console +from rich.table import Table + +from cli_tool.config_cmd.core.descriptions import SECTION_DESCRIPTIONS +from cli_tool.utils.config_manager import list_config_sections + +console = Console() + + +@click.command() +def list_sections(): + """List all configuration sections.""" + sections = list_config_sections() + + table = Table(title="Configuration Sections") + table.add_column("Section", style="cyan") + table.add_column("Description", style="dim") + + for section in sections: + desc = SECTION_DESCRIPTIONS.get(section, "") + table.add_row(section, desc) + + console.print(table) diff --git a/cli_tool/config_cmd/commands/set.py b/cli_tool/config_cmd/commands/set.py new file mode 100644 index 0000000..4a2ddc2 --- /dev/null +++ b/cli_tool/config_cmd/commands/set.py @@ -0,0 +1,38 @@ +"""Set configuration value command.""" + +import json + +import click +from rich.console import Console + +from cli_tool.utils.config_manager import set_config_value + +console = Console() + + +@click.command() +@click.argument("key") +@click.argument("value") +def set_command(key, value): + """Set a configuration value. + + Examples: + + # Set Bedrock model ID + devo config set bedrock.model_id "new-model-id" + + # Enable version check + devo config set version_check.enabled true + """ + # Try to parse value as JSON (for booleans, numbers, objects) + try: + parsed_value = json.loads(value) + except json.JSONDecodeError: + # Keep as string if not valid JSON + parsed_value = value + + try: + set_config_value(key, parsed_value) + console.print(f"[green]✓ Set {key} = {parsed_value}[/green]") + except Exception as e: + console.print(f"[red]✗ Failed to set value: {e}[/red]") diff --git a/cli_tool/config_cmd/commands/show.py b/cli_tool/config_cmd/commands/show.py new file mode 100644 index 0000000..b714733 --- /dev/null +++ b/cli_tool/config_cmd/commands/show.py @@ -0,0 +1,32 @@ +"""Show configuration command.""" + +import json + +import click +from rich.console import Console +from rich.syntax import Syntax + +from cli_tool.utils.config_manager import list_config_sections, load_config + +console = Console() + + +@click.command() +@click.option("--section", "-s", help="Show only specific section (e.g., ssm, dynamodb)") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +def show_config(section, as_json): + """Show current configuration.""" + config = load_config() + + if section: + if section not in config: + console.print(f"[red]✗ Section '{section}' not found[/red]") + console.print(f"Available sections: {', '.join(list_config_sections())}") + return + config = {section: config[section]} + + if as_json: + console.print(json.dumps(config, indent=2)) + else: + syntax = Syntax(json.dumps(config, indent=2), "json", theme="monokai", line_numbers=True) + console.print(syntax) diff --git a/cli_tool/config_cmd/core/__init__.py b/cli_tool/config_cmd/core/__init__.py new file mode 100644 index 0000000..0a23116 --- /dev/null +++ b/cli_tool/config_cmd/core/__init__.py @@ -0,0 +1,5 @@ +"""Configuration core logic.""" + +from cli_tool.config_cmd.core.descriptions import SECTION_DESCRIPTIONS + +__all__ = ["SECTION_DESCRIPTIONS"] diff --git a/cli_tool/config_cmd/core/descriptions.py b/cli_tool/config_cmd/core/descriptions.py new file mode 100644 index 0000000..beae5b7 --- /dev/null +++ b/cli_tool/config_cmd/core/descriptions.py @@ -0,0 +1,10 @@ +"""Configuration section descriptions.""" + +SECTION_DESCRIPTIONS = { + "bedrock": "AWS Bedrock AI model settings", + "github": "GitHub repository configuration", + "codeartifact": "AWS CodeArtifact settings", + "version_check": "Version check preferences", + "ssm": "SSM connection configurations", + "dynamodb": "DynamoDB export templates", +} From cf58ba1708a425919f0168cdd1e279e4022d5a5d Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Mon, 2 Mar 2026 00:51:08 -0500 Subject: [PATCH 09/37] refactor(dynamodb): restructure into modular command and core architecture - Extract index detection logic into dedicated core module (detect_usable_index) - Extract multi-query execution into core module (execute_multi_query) - Extract parallel scan detection into core module (should_use_parallel_scan) - Move query optimization and execution logic from export_table.py to reusable core functions - Add query_optimizer.py for centralized query optimization logic - Add multi_query_executor.py for handling multiple query execution patterns - Add core/README.md documenting the modular architecture - Update export_table.py to use new core modules instead of inline implementations - Improve CLI help text with auto-detection indicators and [Advanced] labels for manual options - Update export_table command examples to showcase filter-based auto-optimization --- cli_tool/commands/dynamodb.py | 19 +- cli_tool/dynamodb/commands/export_table.py | 201 +-------------- cli_tool/dynamodb/core/README.md | 73 ++++++ cli_tool/dynamodb/core/__init__.py | 5 + .../dynamodb/core/multi_query_executor.py | 149 +++++++++++ cli_tool/dynamodb/core/query_optimizer.py | 235 ++++++++++++++++++ 6 files changed, 483 insertions(+), 199 deletions(-) create mode 100644 cli_tool/dynamodb/core/README.md create mode 100644 cli_tool/dynamodb/core/multi_query_executor.py create mode 100644 cli_tool/dynamodb/core/query_optimizer.py diff --git a/cli_tool/commands/dynamodb.py b/cli_tool/commands/dynamodb.py index 77cec1e..105e8ca 100644 --- a/cli_tool/commands/dynamodb.py +++ b/cli_tool/commands/dynamodb.py @@ -86,23 +86,23 @@ def describe_table(ctx, table_name: str, region: str): ) @click.option( "--filter", - help="Filter expression for scan/query", + help="Filter expression for scan/query (auto-detects indexes for optimization)", ) @click.option( "--filter-values", - help='Expression attribute values as JSON (e.g., \'{":val": "active"}\')', + help='[Advanced] Expression attribute values as JSON (e.g., \'{":val": "active"}\')', ) @click.option( "--filter-names", - help='Expression attribute names as JSON (e.g., \'{"#status": "status"}\')', + help='[Advanced] Expression attribute names as JSON (e.g., \'{"#status": "status"}\')', ) @click.option( "--key-condition", - help="Key condition expression for query (requires partition key)", + help="[Advanced] Manual key condition expression (auto-detected from --filter in most cases)", ) @click.option( "--index", - help="Global or Local Secondary Index name to use", + help="[Advanced] Force specific GSI/LSI (auto-selected from --filter in most cases)", ) @click.option( "--mode", @@ -214,14 +214,17 @@ def export_table( # Export entire table to CSV devo dynamodb export my-table + # Export with filter (auto-detects indexes and optimizes query) + devo dynamodb export my-table --filter "userId = user123" + # Export with specific attributes - devo dynamodb export my-table -a "id,name,email" + devo dynamodb export my-table -a "id,name,email" --filter "status = active" # Export to JSON with compression devo dynamodb export my-table -f json --compress gzip - # Query with key condition - devo dynamodb export my-table --key-condition "userId = :uid" + # Advanced: Manual key condition (rarely needed, auto-detected from --filter) + devo dynamodb export my-table --key-condition "userId = :uid" --filter-values '{":uid": "user123"}' """ from cli_tool.utils.aws import select_profile diff --git a/cli_tool/dynamodb/commands/export_table.py b/cli_tool/dynamodb/commands/export_table.py index accb4bd..fc630ff 100644 --- a/cli_tool/dynamodb/commands/export_table.py +++ b/cli_tool/dynamodb/commands/export_table.py @@ -4,205 +4,24 @@ import re import sys from pathlib import Path -from typing import Any, Dict, Optional +from typing import Optional import click from botocore.exceptions import BotoCoreError, ClientError from rich.console import Console -from cli_tool.dynamodb.core import DynamoDBExporter, ParallelScanner -from cli_tool.dynamodb.utils import ExportConfigManager, FilterBuilder, create_template_from_args, estimate_export_size, validate_table_exists +from cli_tool.dynamodb.core import DynamoDBExporter, ParallelScanner, detect_usable_index +from cli_tool.dynamodb.utils import ( + ExportConfigManager, + FilterBuilder, + create_template_from_args, + estimate_export_size, + validate_table_exists, +) console = Console() -def _detect_usable_index( - filter_expression: str, - expression_attribute_names: Optional[Dict[str, str]], - expression_attribute_values: Optional[Dict[str, Any]], - table_info: Dict[str, Any], -) -> Optional[Dict[str, Any]]: - """ - Detect if filter uses an indexed attribute with equality that could be queried. - - Returns dict with: - - index_name: Name of GSI or None for main table - - key_condition: KeyConditionExpression to use - - remaining_filter: FilterExpression for remaining conditions (or None) - - key_attribute: Name of the indexed attribute - - has_or: True if filter contains OR (cannot auto-optimize) - """ - if not filter_expression: - return None - - # Check for OR conditions - cannot auto-optimize with Query - # Normalize the expression by removing parentheses and extra spaces for OR detection - normalized_expr = re.sub(r"[()]", " ", filter_expression) - normalized_expr = re.sub(r"\s+", " ", normalized_expr) - has_or = " OR " in normalized_expr.upper() - if has_or: - # Still detect indexed attributes for suggestions, but don't auto-optimize - equality_pattern = r"(#?\w+)\s*=\s*(:\w+)" - equality_matches = re.findall(equality_pattern, filter_expression) - - if not equality_matches: - return None - - # Find indexed attributes - indexed_attrs = [] - - # Check GSIs - for gsi in table_info.get("global_indexes", []): - # Validate GSI is active - gsi_status = gsi.get("IndexStatus", "ACTIVE") - if gsi_status != "ACTIVE": - continue # Skip non-active indexes - - key_schema = gsi.get("KeySchema", []) - for key in key_schema: - if key.get("KeyType") == "HASH": - key_name = key.get("AttributeName") - # Check if this key is in the filter - for attr, value_placeholder in equality_matches: - resolved_attr = attr - if attr.startswith("#") and expression_attribute_names: - resolved_attr = expression_attribute_names.get(attr, attr) - if resolved_attr == key_name: - indexed_attrs.append( - { - "key_attribute": key_name, - "index_name": gsi.get("IndexName"), - "attr_ref": attr, - "value_ref": value_placeholder, - } - ) - - # Check main table key - for key in table_info.get("key_schema", []): - if key.get("KeyType") == "HASH": - key_name = key.get("AttributeName") - for attr, value_placeholder in equality_matches: - resolved_attr = attr - if attr.startswith("#") and expression_attribute_names: - resolved_attr = expression_attribute_names.get(attr, attr) - if resolved_attr == key_name: - indexed_attrs.append( - { - "key_attribute": key_name, - "index_name": None, - "attr_ref": attr, - "value_ref": value_placeholder, - } - ) - - if indexed_attrs: - # Return info but mark as having OR - return { - "has_or": True, - "indexed_attributes": indexed_attrs, - "filter_expression": filter_expression, - } - - return None - - # No OR - proceed with normal detection - # Extract equality conditions: "attributeName = :value" or "#attr = :value" - equality_pattern = r"(#?\w+)\s*=\s*(:\w+)" - equality_matches = re.findall(equality_pattern, filter_expression) - - if not equality_matches: - return None - - # Build map of attribute -> value placeholder - equality_conditions = {} - for attr, value_placeholder in equality_matches: - # Resolve attribute name if it's an alias - resolved_attr = attr - if attr.startswith("#") and expression_attribute_names: - resolved_attr = expression_attribute_names.get(attr, attr) - - equality_conditions[resolved_attr] = { - "attr_ref": attr, # Original reference (might be #attr) - "value_ref": value_placeholder, - } - - # Check GSIs first (usually more specific) - for gsi in table_info.get("global_indexes", []): - # Validate GSI is active - gsi_status = gsi.get("IndexStatus", "ACTIVE") - if gsi_status != "ACTIVE": - continue # Skip non-active indexes - - gsi_name = gsi.get("IndexName") - key_schema = gsi.get("KeySchema", []) - - for key in key_schema: - if key.get("KeyType") == "HASH": # Partition key - key_name = key.get("AttributeName") - if key_name in equality_conditions: - # Found indexed attribute with equality condition - condition_info = equality_conditions[key_name] - - # Build KeyConditionExpression - key_condition = f"{condition_info['attr_ref']} = {condition_info['value_ref']}" - - # Remove this condition from filter to get remaining filter - remaining_filter = filter_expression - # Remove the key condition from filter (handle AND/OR) - condition_pattern = ( - rf'\s*(?:AND|OR)?\s*{re.escape(condition_info["attr_ref"])}\s*=\s*{re.escape(condition_info["value_ref"])}\s*(?:AND|OR)?' - ) - remaining_filter = re.sub(condition_pattern, " ", remaining_filter).strip() - - # Clean up extra AND/OR at start/end - remaining_filter = re.sub(r"^\s*(?:AND|OR)\s+", "", remaining_filter) - remaining_filter = re.sub(r"\s+(?:AND|OR)\s*$", "", remaining_filter) - remaining_filter = remaining_filter.strip() - - if not remaining_filter or remaining_filter in ("()", ""): - remaining_filter = None - - return { - "has_or": False, - "index_name": gsi_name, - "key_condition": key_condition, - "remaining_filter": remaining_filter, - "key_attribute": key_name, - } - - # Check table's main partition key - for key in table_info.get("key_schema", []): - if key.get("KeyType") == "HASH": - key_name = key.get("AttributeName") - if key_name in equality_conditions: - condition_info = equality_conditions[key_name] - - key_condition = f"{condition_info['attr_ref']} = {condition_info['value_ref']}" - - # Remove this condition from filter - remaining_filter = filter_expression - condition_pattern = ( - rf'\s*(?:AND|OR)?\s*{re.escape(condition_info["attr_ref"])}\s*=\s*{re.escape(condition_info["value_ref"])}\s*(?:AND|OR)?' - ) - remaining_filter = re.sub(condition_pattern, " ", remaining_filter).strip() - remaining_filter = re.sub(r"^\s*(?:AND|OR)\s+", "", remaining_filter) - remaining_filter = re.sub(r"\s+(?:AND|OR)\s*$", "", remaining_filter) - remaining_filter = remaining_filter.strip() - - if not remaining_filter or remaining_filter in ("()", ""): - remaining_filter = None - - return { - "has_or": False, - "index_name": None, # Use main table - "key_condition": key_condition, - "remaining_filter": remaining_filter, - "key_attribute": key_name, - } - - return None - - def export_table_command( profile: Optional[str], table_name: str, @@ -338,7 +157,7 @@ def export_table_command( table_info = exporter.get_table_info() # Try to detect if filter uses an indexed attribute with equality - auto_detected_index = _detect_usable_index(filter, expression_attribute_names, expression_attribute_values, table_info) + auto_detected_index = detect_usable_index(filter, expression_attribute_names, expression_attribute_values, table_info) if auto_detected_index: if auto_detected_index.get("has_or"): diff --git a/cli_tool/dynamodb/core/README.md b/cli_tool/dynamodb/core/README.md new file mode 100644 index 0000000..8e03417 --- /dev/null +++ b/cli_tool/dynamodb/core/README.md @@ -0,0 +1,73 @@ +# DynamoDB Core Modules + +This directory contains the core business logic for DynamoDB operations, separated from CLI concerns. + +## Modules + +### `exporter.py` +Main DynamoDB export functionality. Handles reading data from tables and writing to various formats (CSV, JSON, JSONL). + +### `parallel_scanner.py` +Parallel scan implementation for faster exports of large tables. Splits table into segments and scans them concurrently. + +### `query_optimizer.py` +**Smart query optimization** - Automatically detects when filters can use indexes for better performance. + +Features: +- Auto-detects partition key equality conditions in filters +- Identifies usable GSI/LSI indexes +- Handles OR conditions with multiple indexed attributes +- Suggests optimal query strategies + +Example: +```python +# User provides simple filter +filter = "userId = user123" + +# query_optimizer automatically: +# 1. Detects userId is a partition key +# 2. Converts to KeyConditionExpression +# 3. Uses Query instead of Scan (much faster!) +``` + +### `multi_query_executor.py` +Executes multiple queries in parallel for OR-optimized filters. + +When a filter has OR conditions with multiple indexed attributes: +```python +filter = "userId = user1 OR email = user@example.com" +``` + +The executor: +1. Splits into separate queries (one per indexed attribute) +2. Executes queries in parallel +3. Deduplicates results by primary key +4. Combines into single result set + +This is much faster than a full table scan with filter. + +## Design Philosophy + +### Separation of Concerns +- **Commands** (`cli_tool/dynamodb/commands/`) - CLI interface, Click decorators, user interaction +- **Core** (`cli_tool/dynamodb/core/`) - Business logic, no CLI dependencies +- **Utils** (`cli_tool/dynamodb/utils/`) - Helper functions, templates, filters + +### Auto-Detection Over Manual Configuration +Users should rarely need to specify `--key-condition` or `--index` manually. The query optimizer handles this automatically in 90% of cases. + +**Simple usage (recommended):** +```bash +devo dynamodb export my-table --filter "userId = user123" +``` + +**Advanced usage (rarely needed):** +```bash +devo dynamodb export my-table --key-condition "userId = :uid" --filter-values '{":uid": "user123"}' +``` + +### Benefits +1. **Easier to use** - Users don't need to understand DynamoDB internals +2. **Better performance** - Automatic optimization uses the fastest query method +3. **Testable** - Core logic can be tested without CLI +4. **Reusable** - Core modules can be used in other contexts (APIs, scripts, etc.) diff --git a/cli_tool/dynamodb/core/__init__.py b/cli_tool/dynamodb/core/__init__.py index f817bd8..acae073 100644 --- a/cli_tool/dynamodb/core/__init__.py +++ b/cli_tool/dynamodb/core/__init__.py @@ -1,9 +1,14 @@ """DynamoDB core functionality.""" from cli_tool.dynamodb.core.exporter import DynamoDBExporter +from cli_tool.dynamodb.core.multi_query_executor import execute_multi_query from cli_tool.dynamodb.core.parallel_scanner import ParallelScanner +from cli_tool.dynamodb.core.query_optimizer import detect_usable_index, should_use_parallel_scan __all__ = [ "DynamoDBExporter", "ParallelScanner", + "detect_usable_index", + "execute_multi_query", + "should_use_parallel_scan", ] diff --git a/cli_tool/dynamodb/core/multi_query_executor.py b/cli_tool/dynamodb/core/multi_query_executor.py new file mode 100644 index 0000000..1b31aec --- /dev/null +++ b/cli_tool/dynamodb/core/multi_query_executor.py @@ -0,0 +1,149 @@ +"""Multi-query execution for OR-optimized DynamoDB queries.""" + +import json +from typing import Any, Dict, List, Optional + +from botocore.exceptions import ClientError +from rich.console import Console + +console = Console() + + +def execute_multi_query( + exporter, + query_configs: List[Dict[str, Any]], + projection_expression: Optional[str], + expression_attribute_values: Optional[Dict[str, Any]], + expression_attribute_names: Optional[Dict[str, str]], + limit: Optional[int], + table_info: Dict[str, Any], +) -> List[Dict[str, Any]]: + """ + Execute multiple queries and combine results with deduplication. + + Args: + exporter: DynamoDBExporter instance + query_configs: List of query configurations + projection_expression: Attributes to project + expression_attribute_values: Expression attribute values + expression_attribute_names: Expression attribute names + limit: Maximum total items to return + table_info: Table information for deduplication + + Returns: + List of deduplicated items + """ + console.print(f"[cyan]Using Multiple Queries ({len(query_configs)} queries for OR optimization)[/cyan]") + + # Calculate limit per query + limit_per_query = None + if limit: + limit_per_query = int(limit * 1.5 / len(query_configs)) + 100 + console.print(f"[cyan] Limit per query: ~{limit_per_query} items (total limit: {limit})[/cyan]") + + all_items = [] + seen_keys = set() + + # Get primary key attributes for deduplication + primary_key_attrs = [key["AttributeName"] for key in table_info.get("key_schema", [])] + + for idx, query_config in enumerate(query_configs, 1): + console.print(f"\n[cyan]Executing query {idx}/{len(query_configs)}...[/cyan]") + + # Extract values for this specific query + query_values = _extract_query_values( + query_config["key_condition"], + expression_attribute_values, + ) + + try: + query_items = exporter.query_table( + key_condition_expression=query_config["key_condition"], + filter_expression=None, + projection_expression=projection_expression, + index_name=query_config.get("index_name"), + limit=limit_per_query, + expression_attribute_values=query_values, + expression_attribute_names=expression_attribute_names, + ) + + # Deduplicate items + for item in query_items: + item_key = _create_item_key(item, primary_key_attrs) + + if item_key not in seen_keys: + seen_keys.add(item_key) + all_items.append(item) + + if limit and len(all_items) >= limit: + break + + # Stop if we've reached the limit + if limit and len(all_items) >= limit: + console.print(f"[cyan]Reached limit of {limit} items, stopping remaining queries[/cyan]") + break + + except ClientError as e: + if e.response["Error"]["Code"] == "ProvisionedThroughputExceededException": + console.print(f"[yellow]⚠ Rate limit exceeded on query {idx}, waiting 1 second...[/yellow]") + import time + + time.sleep(1) + + # Retry + query_items = exporter.query_table( + key_condition_expression=query_config["key_condition"], + filter_expression=None, + projection_expression=projection_expression, + index_name=query_config.get("index_name"), + limit=limit_per_query, + expression_attribute_values=query_values, + expression_attribute_names=expression_attribute_names, + ) + + # Deduplicate retry results + for item in query_items: + item_key = _create_item_key(item, primary_key_attrs) + if item_key not in seen_keys: + seen_keys.add(item_key) + all_items.append(item) + if limit and len(all_items) >= limit: + break + else: + raise + + console.print(f"\n[green]✓ Combined {len(all_items)} unique items from {len(query_configs)} queries[/green]") + + # Apply final limit + if limit and len(all_items) > limit: + all_items = all_items[:limit] + console.print(f"[cyan]Applied final limit: {limit} items[/cyan]") + + return all_items + + +def _extract_query_values( + key_condition: str, + expression_attribute_values: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + """Extract only the values needed for a specific query.""" + if not expression_attribute_values: + return {} + + # Find which value placeholder is used in this key condition + value_placeholder = key_condition.split("=")[1].strip() + + query_values = {} + if value_placeholder in expression_attribute_values: + query_values[value_placeholder] = expression_attribute_values[value_placeholder] + + return query_values + + +def _create_item_key(item: Dict[str, Any], primary_key_attrs: List[str]) -> str: + """Create unique key from primary key attributes.""" + key_parts = [] + for pk_attr in primary_key_attrs: + if pk_attr in item: + key_parts.append(f"{pk_attr}={json.dumps(item[pk_attr], sort_keys=True, default=str)}") + return "|".join(key_parts) diff --git a/cli_tool/dynamodb/core/query_optimizer.py b/cli_tool/dynamodb/core/query_optimizer.py new file mode 100644 index 0000000..43589a4 --- /dev/null +++ b/cli_tool/dynamodb/core/query_optimizer.py @@ -0,0 +1,235 @@ +"""Query optimization utilities for DynamoDB exports.""" + +import re +from typing import Any, Dict, Optional + + +def detect_usable_index( + filter_expression: str, + expression_attribute_names: Optional[Dict[str, str]], + expression_attribute_values: Optional[Dict[str, Any]], + table_info: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """ + Detect if filter uses an indexed attribute with equality that could be queried. + + Returns dict with: + - index_name: Name of GSI or None for main table + - key_condition: KeyConditionExpression to use + - remaining_filter: FilterExpression for remaining conditions (or None) + - key_attribute: Name of the indexed attribute + - has_or: True if filter contains OR (cannot auto-optimize) + """ + if not filter_expression: + return None + + # Check for OR conditions - cannot auto-optimize with Query + normalized_expr = re.sub(r"[()]", " ", filter_expression) + normalized_expr = re.sub(r"\s+", " ", normalized_expr) + has_or = " OR " in normalized_expr.upper() + + if has_or: + return _detect_or_indexed_attributes( + filter_expression, + expression_attribute_names, + table_info, + ) + + # No OR - proceed with normal detection + return _detect_single_indexed_attribute( + filter_expression, + expression_attribute_names, + table_info, + ) + + +def _detect_or_indexed_attributes( + filter_expression: str, + expression_attribute_names: Optional[Dict[str, str]], + table_info: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """Detect indexed attributes in OR conditions.""" + equality_pattern = r"(#?\w+)\s*=\s*(:\w+)" + equality_matches = re.findall(equality_pattern, filter_expression) + + if not equality_matches: + return None + + indexed_attrs = [] + + # Check GSIs + for gsi in table_info.get("global_indexes", []): + gsi_status = gsi.get("IndexStatus", "ACTIVE") + if gsi_status != "ACTIVE": + continue + + key_schema = gsi.get("KeySchema", []) + for key in key_schema: + if key.get("KeyType") == "HASH": + key_name = key.get("AttributeName") + for attr, value_placeholder in equality_matches: + resolved_attr = _resolve_attribute_name(attr, expression_attribute_names) + if resolved_attr == key_name: + indexed_attrs.append( + { + "key_attribute": key_name, + "index_name": gsi.get("IndexName"), + "attr_ref": attr, + "value_ref": value_placeholder, + } + ) + + # Check main table key + for key in table_info.get("key_schema", []): + if key.get("KeyType") == "HASH": + key_name = key.get("AttributeName") + for attr, value_placeholder in equality_matches: + resolved_attr = _resolve_attribute_name(attr, expression_attribute_names) + if resolved_attr == key_name: + indexed_attrs.append( + { + "key_attribute": key_name, + "index_name": None, + "attr_ref": attr, + "value_ref": value_placeholder, + } + ) + + if indexed_attrs: + return { + "has_or": True, + "indexed_attributes": indexed_attrs, + "filter_expression": filter_expression, + } + + return None + + +def _detect_single_indexed_attribute( + filter_expression: str, + expression_attribute_names: Optional[Dict[str, str]], + table_info: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """Detect single indexed attribute with equality condition.""" + equality_pattern = r"(#?\w+)\s*=\s*(:\w+)" + equality_matches = re.findall(equality_pattern, filter_expression) + + if not equality_matches: + return None + + # Build map of attribute -> value placeholder + equality_conditions = {} + for attr, value_placeholder in equality_matches: + resolved_attr = _resolve_attribute_name(attr, expression_attribute_names) + equality_conditions[resolved_attr] = { + "attr_ref": attr, + "value_ref": value_placeholder, + } + + # Check GSIs first (usually more specific) + for gsi in table_info.get("global_indexes", []): + gsi_status = gsi.get("IndexStatus", "ACTIVE") + if gsi_status != "ACTIVE": + continue + + gsi_name = gsi.get("IndexName") + key_schema = gsi.get("KeySchema", []) + + for key in key_schema: + if key.get("KeyType") == "HASH": + key_name = key.get("AttributeName") + if key_name in equality_conditions: + condition_info = equality_conditions[key_name] + key_condition = f"{condition_info['attr_ref']} = {condition_info['value_ref']}" + remaining_filter = _remove_condition_from_filter( + filter_expression, + condition_info["attr_ref"], + condition_info["value_ref"], + ) + + return { + "has_or": False, + "index_name": gsi_name, + "key_condition": key_condition, + "remaining_filter": remaining_filter, + "key_attribute": key_name, + } + + # Check table's main partition key + for key in table_info.get("key_schema", []): + if key.get("KeyType") == "HASH": + key_name = key.get("AttributeName") + if key_name in equality_conditions: + condition_info = equality_conditions[key_name] + key_condition = f"{condition_info['attr_ref']} = {condition_info['value_ref']}" + remaining_filter = _remove_condition_from_filter( + filter_expression, + condition_info["attr_ref"], + condition_info["value_ref"], + ) + + return { + "has_or": False, + "index_name": None, + "key_condition": key_condition, + "remaining_filter": remaining_filter, + "key_attribute": key_name, + } + + return None + + +def _resolve_attribute_name( + attr: str, + expression_attribute_names: Optional[Dict[str, str]], +) -> str: + """Resolve attribute name if it's an alias.""" + if attr.startswith("#") and expression_attribute_names: + return expression_attribute_names.get(attr, attr) + return attr + + +def _remove_condition_from_filter( + filter_expression: str, + attr_ref: str, + value_ref: str, +) -> Optional[str]: + """Remove a condition from filter expression.""" + condition_pattern = rf"\s*(?:AND|OR)?\s*{re.escape(attr_ref)}\s*=\s*{re.escape(value_ref)}\s*(?:AND|OR)?" + remaining_filter = re.sub(condition_pattern, " ", filter_expression).strip() + + # Clean up extra AND/OR at start/end + remaining_filter = re.sub(r"^\s*(?:AND|OR)\s+", "", remaining_filter) + remaining_filter = re.sub(r"\s+(?:AND|OR)\s*$", "", remaining_filter) + remaining_filter = remaining_filter.strip() + + if not remaining_filter or remaining_filter in ("()", ""): + return None + + return remaining_filter + + +def should_use_parallel_scan(item_count: int, use_parallel: bool) -> tuple[bool, int]: + """ + Determine if parallel scan should be used and how many segments. + + Args: + item_count: Number of items in the table + use_parallel: User's preference for parallel scan + + Returns: + Tuple of (should_use_parallel, segments) + """ + if use_parallel: + return True, 4 # User explicitly requested + + # Auto-enable for large tables + if item_count > 100000: + if item_count > 1000000: + return True, 16 + elif item_count > 500000: + return True, 12 + else: + return True, 8 + + return False, 4 From 4c6112468c1b047904b7dddc13e3034c0300db69 Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Mon, 2 Mar 2026 00:57:57 -0500 Subject: [PATCH 10/37] refactor(code_reviewer): restructure into modular command and core architecture - Move analyzer.py and git_utils.py to core/ subdirectory for business logic separation - Create commands/ subdirectory with analyze.py for CLI interface - Add commands/__init__.py with command registration and routing logic - Update code_reviewer/__init__.py with public API exports (CodeReviewAnalyzer, GitManager) - Add comprehensive README.md documenting module structure, usage, and architecture - Separate concerns: commands layer handles Click/UI, core layer contains business logic - Align with established modular architecture pattern used across other CLI modules --- cli_tool/code_reviewer/README.md | 83 +++++++++++++ cli_tool/code_reviewer/__init__.py | 6 + cli_tool/code_reviewer/commands/__init__.py | 22 ++++ cli_tool/code_reviewer/commands/analyze.py | 111 +++++++++++++++++ cli_tool/code_reviewer/core/__init__.py | 6 + cli_tool/code_reviewer/{ => core}/analyzer.py | 2 +- .../code_reviewer/{ => core}/git_utils.py | 0 cli_tool/commands/code_reviewer.py | 116 +----------------- 8 files changed, 232 insertions(+), 114 deletions(-) create mode 100644 cli_tool/code_reviewer/README.md create mode 100644 cli_tool/code_reviewer/commands/__init__.py create mode 100644 cli_tool/code_reviewer/commands/analyze.py create mode 100644 cli_tool/code_reviewer/core/__init__.py rename cli_tool/code_reviewer/{ => core}/analyzer.py (99%) rename cli_tool/code_reviewer/{ => core}/git_utils.py (100%) diff --git a/cli_tool/code_reviewer/README.md b/cli_tool/code_reviewer/README.md new file mode 100644 index 0000000..545367e --- /dev/null +++ b/cli_tool/code_reviewer/README.md @@ -0,0 +1,83 @@ +# Code Reviewer + +AI-Powered Code Analysis for Pull Requests using AWS Bedrock. + +## Structure + +``` +cli_tool/code_reviewer/ +├── __init__.py # Public API exports +├── README.md # This file +├── TODO.md # Feature roadmap +├── commands/ # CLI command definitions +│ ├── __init__.py # Command registration +│ └── analyze.py # Main analysis command +├── core/ # Business logic +│ ├── __init__.py +│ ├── analyzer.py # CodeReviewAnalyzer class +│ └── git_utils.py # GitManager for git operations +├── prompt/ # AI prompts and rules +│ ├── __init__.py +│ ├── analysis_rules.py # Analysis guidelines +│ ├── code_reviewer.py # Main prompts +│ ├── output_format.py # Output structure +│ ├── security_standards.py # Security checks +│ └── tools_guide.py # Tool usage guide +└── tools/ # AI agent tools + ├── __init__.py + ├── code_analyzer.py # Code analysis tools + ├── file_reader.py # File reading tools + └── README.md # Tools documentation +``` + +## Usage + +```bash +# Analyze PR changes (current branch vs main) +devo code-reviewer + +# Analyze PR changes vs specific branch +devo code-reviewer --base-branch develop + +# Get JSON output for CI/CD integration +devo code-reviewer --output json + +# Show detailed execution metrics +devo code-reviewer --show-metrics + +# Use full detailed prompt (more comprehensive but slower) +devo code-reviewer --full-prompt +``` + +## Features + +- AI-powered code analysis using AWS Bedrock (Claude 3.7 Sonnet) +- Analyzes git diffs between branches +- Detects security issues, code quality problems, and breaking changes +- Structured JSON output for CI/CD integration +- Rich terminal UI with tables and formatting +- Execution metrics and performance tracking + +## Architecture + +### Commands Layer (`commands/`) +- CLI interface using Click +- User input validation +- Output formatting with Rich +- No business logic + +### Core Layer (`core/`) +- `analyzer.py`: Main analysis logic using BaseAgent +- `git_utils.py`: Git operations (diffs, branches, file changes) +- No Click dependencies + +### Prompt Layer (`prompt/`) +- AI system prompts +- Analysis rules and guidelines +- Security standards +- Output format specifications + +### Tools Layer (`tools/`) +- AI agent tools for code analysis +- File reading and context gathering +- Import analysis and reference search diff --git a/cli_tool/code_reviewer/__init__.py b/cli_tool/code_reviewer/__init__.py index e69de29..794ea08 100755 --- a/cli_tool/code_reviewer/__init__.py +++ b/cli_tool/code_reviewer/__init__.py @@ -0,0 +1,6 @@ +"""Code Reviewer - AI-Powered Code Analysis.""" + +from cli_tool.code_reviewer.core.analyzer import CodeReviewAnalyzer +from cli_tool.code_reviewer.core.git_utils import GitManager + +__all__ = ["CodeReviewAnalyzer", "GitManager"] diff --git a/cli_tool/code_reviewer/commands/__init__.py b/cli_tool/code_reviewer/commands/__init__.py new file mode 100644 index 0000000..48d3fe4 --- /dev/null +++ b/cli_tool/code_reviewer/commands/__init__.py @@ -0,0 +1,22 @@ +"""Code Reviewer commands.""" + +import click + +from cli_tool.code_reviewer.commands.analyze import analyze + + +def register_code_reviewer_commands(parent_group): + """Register code reviewer commands.""" + + @parent_group.command("code-reviewer") + @click.pass_context + def code_reviewer(ctx): + """🚀 Code Reviewer - AI-Powered Code Analysis""" + pass + + # Register analyze as the main command + code_reviewer.add_command(analyze, "analyze") + + # Make analyze the default command when no subcommand is provided + code_reviewer.callback = analyze.callback + code_reviewer.params = analyze.params diff --git a/cli_tool/code_reviewer/commands/analyze.py b/cli_tool/code_reviewer/commands/analyze.py new file mode 100644 index 0000000..31f7bb7 --- /dev/null +++ b/cli_tool/code_reviewer/commands/analyze.py @@ -0,0 +1,111 @@ +"""Code review analysis command.""" + +import json +import sys +from typing import Optional + +import click + +from cli_tool.code_reviewer.core.analyzer import CodeReviewAnalyzer +from cli_tool.ui.console_ui import console_ui + + +@click.command() +@click.option( + "--base-branch", + "-b", + default=None, + help="Base branch to compare against (default: auto-detect main/master)", +) +@click.option( + "--repo-path", + "-r", + default=None, + help="Path to the Git repository (default: current directory)", +) +@click.option( + "--output", + "-o", + type=click.Choice(["json", "table"]), + default="table", + help="Output format (table: rich tables, json: raw JSON)", +) +@click.option( + "--show-metrics", + "-m", + is_flag=True, + default=False, + help="Include detailed execution metrics in the output", +) +@click.option( + "--full-prompt", + "-f", + is_flag=True, + default=False, + help="Use full detailed prompt (default: optimized short prompt)", +) +@click.pass_context +def analyze( + ctx, + base_branch: Optional[str], + repo_path: Optional[str], + output: str, + show_metrics: bool, + full_prompt: bool, +): + """ + Analyze code changes in your Git repository using AI agents. + Perfect for PR reviews and continuous integration pipelines. + + Examples: + # Analyze PR changes (current branch vs main) + devo code-reviewer + + # Analyze PR changes vs specific branch + devo code-reviewer --base-branch develop + + # Get JSON output for CI/CD integration + devo code-reviewer --output json + + # Get table output (default and most readable) + devo code-reviewer --output table + + # Show detailed execution metrics + devo code-reviewer --show-metrics + + # Use full detailed prompt (more comprehensive but slower) + devo code-reviewer --full-prompt + + # Combine options + devo code-reviewer --base-branch develop --show-metrics --output json --full-prompt + + # Use specific AWS profile + devo --profile my-profile code-reviewer + """ + try: + from cli_tool.utils.aws import select_profile + + profile = select_profile(ctx.obj.get("profile")) + analyzer = CodeReviewAnalyzer(profile_name=profile) + result = analyzer.analyze_pr( + base_branch=base_branch, + repo_path=repo_path, + use_short_prompt=not full_prompt, + ) + + if output == "json": + if not show_metrics: + # Remove metrics from JSON output unless explicitly requested + result_copy = result.copy() + result_copy.pop("metrics", None) + click.echo(json.dumps(result_copy, indent=2)) + else: + click.echo(json.dumps(result, indent=2)) + else: + # For table output, show results in a rich table + # Pass the show_metrics flag to control metrics display + console_ui.show_analysis_results_table(result, show_metrics=show_metrics) + + except Exception as e: + click.echo(f"❌ Error: {e}", err=True) + sys.exit(1) diff --git a/cli_tool/code_reviewer/core/__init__.py b/cli_tool/code_reviewer/core/__init__.py new file mode 100644 index 0000000..7e14848 --- /dev/null +++ b/cli_tool/code_reviewer/core/__init__.py @@ -0,0 +1,6 @@ +"""Code Reviewer core business logic.""" + +from cli_tool.code_reviewer.core.analyzer import CodeReviewAnalyzer +from cli_tool.code_reviewer.core.git_utils import GitManager + +__all__ = ["CodeReviewAnalyzer", "GitManager"] diff --git a/cli_tool/code_reviewer/analyzer.py b/cli_tool/code_reviewer/core/analyzer.py similarity index 99% rename from cli_tool/code_reviewer/analyzer.py rename to cli_tool/code_reviewer/core/analyzer.py index 170d3ea..efdcab6 100644 --- a/cli_tool/code_reviewer/analyzer.py +++ b/cli_tool/code_reviewer/core/analyzer.py @@ -8,7 +8,7 @@ from typing import Dict, Optional from cli_tool.agents.base_agent import BaseAgent -from cli_tool.code_reviewer.git_utils import GitManager +from cli_tool.code_reviewer.core.git_utils import GitManager from cli_tool.code_reviewer.prompt.code_reviewer import ( CODE_REVIEWER_PROMPT, CODE_REVIEWER_PROMPT_SHORT, diff --git a/cli_tool/code_reviewer/git_utils.py b/cli_tool/code_reviewer/core/git_utils.py similarity index 100% rename from cli_tool/code_reviewer/git_utils.py rename to cli_tool/code_reviewer/core/git_utils.py diff --git a/cli_tool/commands/code_reviewer.py b/cli_tool/commands/code_reviewer.py index 299699b..6bc4d49 100644 --- a/cli_tool/commands/code_reviewer.py +++ b/cli_tool/commands/code_reviewer.py @@ -1,115 +1,5 @@ -""" -Main entry point for the Code Reviewer application. -""" +"""Thin wrapper for code-reviewer command - imports from cli_tool/code_reviewer/.""" -import json -import sys -from typing import Optional +from cli_tool.code_reviewer.commands.analyze import analyze as code_reviewer -import click - -from cli_tool.code_reviewer.analyzer import CodeReviewAnalyzer -from cli_tool.ui.console_ui import console_ui - - -@click.command() -@click.option( - "--base-branch", - "-b", - default=None, - help="Base branch to compare against (default: auto-detect main/master)", -) -@click.option( - "--repo-path", - "-r", - default=None, - help="Path to the Git repository (default: current directory)", -) -@click.option( - "--output", - "-o", - type=click.Choice(["json", "table"]), - default="table", - help="Output format (table: rich tables, json: raw JSON)", -) -@click.option( - "--show-metrics", - "-m", - is_flag=True, - default=False, - help="Include detailed execution metrics in the output", -) -@click.option( - "--full-prompt", - "-f", - is_flag=True, - default=False, - help="Use full detailed prompt (default: optimized short prompt)", -) -@click.pass_context -def code_reviewer( - ctx, - base_branch: Optional[str], - repo_path: Optional[str], - output: str, - show_metrics: bool, - full_prompt: bool, -): - """ - 🚀 Code Reviewer - AI-Powered Code Analysis - - Analyze code changes in your Git repository using AI agents. - Perfect for PR reviews and continuous integration pipelines. - - Examples: - # Analyze PR changes (current branch vs main) - devo code-reviewer - - # Analyze PR changes vs specific branch - devo code-reviewer --base-branch develop - - # Get JSON output for CI/CD integration - devo code-reviewer --output json - - # Get table output (default and most readable) - devo code-reviewer --output table - - # Show detailed execution metrics - devo code-reviewer --show-metrics - - # Use full detailed prompt (more comprehensive but slower) - devo code-reviewer --full-prompt - - # Combine options - devo code-reviewer --base-branch develop --show-metrics --output json --full-prompt - - # Use specific AWS profile - devo --profile my-profile code-reviewer - """ - try: - from cli_tool.utils.aws import select_profile - - profile = select_profile(ctx.obj.get("profile")) - analyzer = CodeReviewAnalyzer(profile_name=profile) - result = analyzer.analyze_pr( - base_branch=base_branch, - repo_path=repo_path, - use_short_prompt=not full_prompt, - ) - - if output == "json": - if not show_metrics: - # Remove metrics from JSON output unless explicitly requested - result_copy = result.copy() - result_copy.pop("metrics", None) - click.echo(json.dumps(result_copy, indent=2)) - else: - click.echo(json.dumps(result, indent=2)) - else: - # For table output, show results in a rich table - # Pass the show_metrics flag to control metrics display - console_ui.show_analysis_results_table(result, show_metrics=show_metrics) - - except Exception as e: - click.echo(f"❌ Error: {e}", err=True) - sys.exit(1) +__all__ = ["code_reviewer"] From 4628df4f96daae84140c007257fc56a623caf75f Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Mon, 2 Mar 2026 01:06:21 -0500 Subject: [PATCH 11/37] refactor(code_reviewer, dynamodb): restructure into modular command and core architecture - Reorganize code_reviewer commands with dedicated commands/ and core/ structure - Rename analyze command to code_reviewer as main entry point - Move DynamoDB CLI logic from cli_tool/commands/dynamodb.py to cli_tool/dynamodb/commands/cli.py - Create thin wrapper in cli_tool/commands/dynamodb.py that imports from feature module - Update code_reviewer/__init__.py to export command directly - Add README.md documentation for DynamoDB module - Update code-organization.md to reflect completion of all feature migrations - Mark Phase 1 standardization as complete with all 10 features migrated - Consolidate all CLI implementations into feature-specific command modules --- .kiro/steering/code-organization.md | 19 +- cli_tool/code_reviewer/commands/__init__.py | 21 +- cli_tool/code_reviewer/commands/analyze.py | 4 +- cli_tool/commands/code_reviewer.py | 2 +- cli_tool/commands/dynamodb.py | 267 +------------------- cli_tool/dynamodb/README.md | 85 +++++++ cli_tool/dynamodb/__init__.py | 4 + cli_tool/dynamodb/commands/cli.py | 266 +++++++++++++++++++ 8 files changed, 374 insertions(+), 294 deletions(-) create mode 100644 cli_tool/dynamodb/README.md create mode 100644 cli_tool/dynamodb/commands/cli.py diff --git a/.kiro/steering/code-organization.md b/.kiro/steering/code-organization.md index df6f2a6..570cf53 100644 --- a/.kiro/steering/code-organization.md +++ b/.kiro/steering/code-organization.md @@ -170,7 +170,9 @@ def save_feature_config(feature_config: Dict): ### ✅ Follows Standard - `cli_tool/ssm/` - Reference implementation with commands/, core/, utils/ - `cli_tool/dynamodb/` - Well organized with commands/, core/, utils/ -- `cli_tool/code_reviewer/` - Good separation with prompt/, tools/ +- `cli_tool/code_reviewer/` - Good separation with commands/, core/, prompt/, tools/ +- `cli_tool/eventbridge/` - Organized with commands/, core/, utils/ +- `cli_tool/config_cmd/` - Organized with commands/, core/ - `cli_tool/aws_login/` - Reorganized with commands/, core/ - `cli_tool/upgrade/` - Reorganized with core/ (single command, no commands/ needed) - `cli_tool/autocomplete/` - Reorganized with commands/, core/ @@ -178,26 +180,27 @@ def save_feature_config(feature_config: Dict): - `cli_tool/commit/` - Reorganized with commands/, core/ ### ⚠️ Needs Refactoring -- `cli_tool/commands/eventbridge.py` - Should be `cli_tool/eventbridge/` with commands/, core/ -- `cli_tool/commands/config.py` - Should be `cli_tool/config_cmd/` with commands/, core/ +None - All features have been migrated! ## Migration Plan -### Phase 1: Standardize Existing Features (Priority Order) +### Phase 1: Standardize Existing Features ✅ COMPLETED 1. ✅ **SSM** - COMPLETED 2. ✅ **AWS Login** - COMPLETED 3. ✅ **Upgrade** - COMPLETED 4. ✅ **Autocomplete** - COMPLETED (renamed from completion) 5. ✅ **CodeArtifact** - COMPLETED 6. ✅ **Commit** - COMPLETED -7. **EventBridge** - Convert `cli_tool/commands/eventbridge.py` → `cli_tool/eventbridge/` -8. **Config** - Convert `cli_tool/commands/config.py` → `cli_tool/config_cmd/` +7. ✅ **EventBridge** - COMPLETED (already had proper structure) +8. ✅ **Config** - COMPLETED (already had proper structure) +9. ✅ **DynamoDB** - COMPLETED (moved CLI logic to commands/cli.py) +10. ✅ **Code Reviewer** - COMPLETED (reorganized with commands/, core/) ### Phase 2: New Features All new features must follow the standard structure from day one. -### Phase 3: Remove cli_tool/commands/ -Once all features are migrated, `cli_tool/commands/` should only contain thin wrappers that import from feature modules. +### Phase 3: Maintenance ✅ COMPLETED +All features now follow the standard structure with thin wrappers in `cli_tool/commands/`. ## Examples diff --git a/cli_tool/code_reviewer/commands/__init__.py b/cli_tool/code_reviewer/commands/__init__.py index 48d3fe4..9c590bf 100644 --- a/cli_tool/code_reviewer/commands/__init__.py +++ b/cli_tool/code_reviewer/commands/__init__.py @@ -1,22 +1,5 @@ """Code Reviewer commands.""" -import click +from cli_tool.code_reviewer.commands.analyze import code_reviewer -from cli_tool.code_reviewer.commands.analyze import analyze - - -def register_code_reviewer_commands(parent_group): - """Register code reviewer commands.""" - - @parent_group.command("code-reviewer") - @click.pass_context - def code_reviewer(ctx): - """🚀 Code Reviewer - AI-Powered Code Analysis""" - pass - - # Register analyze as the main command - code_reviewer.add_command(analyze, "analyze") - - # Make analyze the default command when no subcommand is provided - code_reviewer.callback = analyze.callback - code_reviewer.params = analyze.params +__all__ = ["code_reviewer"] diff --git a/cli_tool/code_reviewer/commands/analyze.py b/cli_tool/code_reviewer/commands/analyze.py index 31f7bb7..02d09e1 100644 --- a/cli_tool/code_reviewer/commands/analyze.py +++ b/cli_tool/code_reviewer/commands/analyze.py @@ -10,7 +10,7 @@ from cli_tool.ui.console_ui import console_ui -@click.command() +@click.command(name="code-reviewer") @click.option( "--base-branch", "-b", @@ -45,7 +45,7 @@ help="Use full detailed prompt (default: optimized short prompt)", ) @click.pass_context -def analyze( +def code_reviewer( ctx, base_branch: Optional[str], repo_path: Optional[str], diff --git a/cli_tool/commands/code_reviewer.py b/cli_tool/commands/code_reviewer.py index 6bc4d49..4ea1715 100644 --- a/cli_tool/commands/code_reviewer.py +++ b/cli_tool/commands/code_reviewer.py @@ -1,5 +1,5 @@ """Thin wrapper for code-reviewer command - imports from cli_tool/code_reviewer/.""" -from cli_tool.code_reviewer.commands.analyze import analyze as code_reviewer +from cli_tool.code_reviewer.commands.analyze import code_reviewer __all__ = ["code_reviewer"] diff --git a/cli_tool/commands/dynamodb.py b/cli_tool/commands/dynamodb.py index 105e8ca..5a764ab 100644 --- a/cli_tool/commands/dynamodb.py +++ b/cli_tool/commands/dynamodb.py @@ -1,266 +1,5 @@ -"""DynamoDB commands.""" +"""DynamoDB command wrapper - imports from cli_tool.dynamodb.""" -from typing import Optional +from cli_tool.dynamodb import dynamodb -import click - -from cli_tool.dynamodb.commands import ( - describe_table_command, - export_table_command, - list_tables_command, - list_templates_command, -) - - -@click.group(name="dynamodb") -@click.pass_context -def dynamodb(ctx): - """DynamoDB utilities for table management and data export.""" - pass - - -@dynamodb.command(name="list") -@click.option( - "--region", - "-r", - default="us-east-1", - help="AWS region (default: us-east-1)", -) -@click.pass_context -def list_tables(ctx, region: str): - """List all DynamoDB tables in the region.""" - from cli_tool.utils.aws import select_profile - - profile = select_profile(ctx.obj.get("profile")) - list_tables_command(profile, region) - - -@dynamodb.command(name="describe") -@click.argument("table_name") -@click.option( - "--region", - "-r", - default="us-east-1", - help="AWS region (default: us-east-1)", -) -@click.pass_context -def describe_table(ctx, table_name: str, region: str): - """Show detailed information about a table.""" - from cli_tool.utils.aws import select_profile - - profile = select_profile(ctx.obj.get("profile")) - describe_table_command(profile, table_name, region) - - -@dynamodb.command(name="export") -@click.argument("table_name") -@click.option( - "--output", - "-o", - type=click.Path(), - help="Output file path (default: _.csv)", -) -@click.option( - "--format", - "-f", - type=click.Choice(["csv", "json", "jsonl", "tsv"], case_sensitive=False), - default="csv", - help="Output format (default: csv)", -) -@click.option( - "--region", - "-r", - default="us-east-1", - help="AWS region (default: us-east-1)", -) -@click.option( - "--limit", - "-l", - type=int, - help="Maximum number of items to export", -) -@click.option( - "--attributes", - "-a", - help="Comma-separated list of attributes to export (ProjectionExpression)", -) -@click.option( - "--filter", - help="Filter expression for scan/query (auto-detects indexes for optimization)", -) -@click.option( - "--filter-values", - help='[Advanced] Expression attribute values as JSON (e.g., \'{":val": "active"}\')', -) -@click.option( - "--filter-names", - help='[Advanced] Expression attribute names as JSON (e.g., \'{"#status": "status"}\')', -) -@click.option( - "--key-condition", - help="[Advanced] Manual key condition expression (auto-detected from --filter in most cases)", -) -@click.option( - "--index", - help="[Advanced] Force specific GSI/LSI (auto-selected from --filter in most cases)", -) -@click.option( - "--mode", - "-m", - type=click.Choice(["strings", "flatten", "normalize"], case_sensitive=False), - default="strings", - help="Export mode: strings (serialize as JSON), flatten (flatten nested), normalize (expand lists to rows)", -) -@click.option( - "--null-value", - default="", - help="Value to use for NULL fields in CSV (default: empty string)", -) -@click.option( - "--delimiter", - default=",", - help="CSV delimiter (default: comma)", -) -@click.option( - "--encoding", - default="utf-8", - help="File encoding (default: utf-8)", -) -@click.option( - "--bool-format", - type=click.Choice(["lowercase", "uppercase", "numeric", "letter"], case_sensitive=False), - default="lowercase", - help="Boolean format: lowercase (true/false), uppercase (True/False), numeric (1/0), letter (t/f) - default: lowercase", -) -@click.option( - "--compress", - type=click.Choice(["gzip", "zip"], case_sensitive=False), - help="Compress output file", -) -@click.option( - "--metadata", - is_flag=True, - help="Include metadata header in CSV output", -) -@click.option( - "--pretty", - is_flag=True, - help="Pretty print JSON output (ignored for JSONL)", -) -@click.option( - "--parallel-scan", - is_flag=True, - help="Use parallel scan for faster export (experimental)", -) -@click.option( - "--segments", - type=int, - default=4, - help="Number of parallel scan segments (default: 4)", -) -@click.option( - "--dry-run", - is_flag=True, - help="Show what would be exported without actually exporting", -) -@click.option( - "--yes", - "-y", - is_flag=True, - help="Skip confirmation prompts", -) -@click.option( - "--save-template", - help="Save current configuration as a template", -) -@click.option( - "--use-template", - help="Use saved template configuration", -) -@click.pass_context -def export_table( - ctx, - table_name: str, - output: Optional[str], - format: str, - region: str, - limit: Optional[int], - attributes: Optional[str], - filter: Optional[str], - filter_values: Optional[str], - filter_names: Optional[str], - key_condition: Optional[str], - index: Optional[str], - mode: str, - null_value: str, - delimiter: str, - encoding: str, - bool_format: str, - compress: Optional[str], - metadata: bool, - pretty: bool, - parallel_scan: bool, - segments: int, - dry_run: bool, - yes: bool, - save_template: Optional[str], - use_template: Optional[str], -): - """ - Export DynamoDB table to CSV, JSON, or JSONL format. - - Examples: - - # Export entire table to CSV - devo dynamodb export my-table - - # Export with filter (auto-detects indexes and optimizes query) - devo dynamodb export my-table --filter "userId = user123" - - # Export with specific attributes - devo dynamodb export my-table -a "id,name,email" --filter "status = active" - - # Export to JSON with compression - devo dynamodb export my-table -f json --compress gzip - - # Advanced: Manual key condition (rarely needed, auto-detected from --filter) - devo dynamodb export my-table --key-condition "userId = :uid" --filter-values '{":uid": "user123"}' - """ - from cli_tool.utils.aws import select_profile - - profile = select_profile(ctx.obj.get("profile")) - export_table_command( - profile=profile, - table_name=table_name, - output=output, - format=format, - region=region, - limit=limit, - attributes=attributes, - filter=filter, - filter_values=filter_values, - filter_names=filter_names, - key_condition=key_condition, - index=index, - mode=mode, - null_value=null_value, - delimiter=delimiter, - encoding=encoding, - bool_format=bool_format, - compress=compress, - metadata=metadata, - pretty=pretty, - parallel_scan=parallel_scan, - segments=segments, - dry_run=dry_run, - yes=yes, - save_template=save_template, - use_template=use_template, - ) - - -@dynamodb.command(name="list-templates") -@click.pass_context -def list_templates_cmd(ctx): - """List all saved export templates.""" - list_templates_command() +__all__ = ["dynamodb"] diff --git a/cli_tool/dynamodb/README.md b/cli_tool/dynamodb/README.md new file mode 100644 index 0000000..6b96775 --- /dev/null +++ b/cli_tool/dynamodb/README.md @@ -0,0 +1,85 @@ +# DynamoDB + +DynamoDB utilities for table management and data export. + +## Structure + +``` +cli_tool/dynamodb/ +├── __init__.py # Public API exports +├── README.md # This file +├── commands/ # CLI command definitions +│ ├── __init__.py # Command exports +│ ├── cli.py # Main CLI group with all commands +│ ├── describe_table.py # Describe table command logic +│ ├── export_table.py # Export table command logic +│ ├── list_tables.py # List tables command logic +│ └── list_templates.py # List templates command logic +├── core/ # Business logic +│ ├── __init__.py +│ ├── exporter.py # Main export logic +│ ├── parallel_scanner.py # Parallel scan implementation +│ ├── query_optimizer.py # Query optimization +│ ├── multi_query_executor.py # Multi-query execution +│ └── README.md # Core documentation +└── utils/ # Utilities + ├── __init__.py + ├── filter_builder.py # Filter expression builder + ├── templates.py # Template management + └── utils.py # General utilities +``` + +## Usage + +```bash +# List all tables +devo dynamodb list + +# Describe a table +devo dynamodb describe my-table + +# Export entire table to CSV +devo dynamodb export my-table + +# Export with filter (auto-detects indexes) +devo dynamodb export my-table --filter "userId = user123" + +# Export specific attributes +devo dynamodb export my-table -a "id,name,email" + +# Export to JSON with compression +devo dynamodb export my-table -f json --compress gzip + +# List saved templates +devo dynamodb list-templates +``` + +## Features + +- Table listing and description +- Smart export with multiple formats (CSV, JSON, JSONL, TSV) +- Auto-detection of indexes for optimized queries +- Filter expression support with automatic optimization +- Parallel scanning for faster exports +- Template system for reusable configurations +- Compression support (gzip, zip) +- Multiple export modes (strings, flatten, normalize) + +## Architecture + +### Commands Layer (`commands/`) +- `cli.py`: Main CLI group with all Click decorators +- Individual command modules: Business logic without Click +- No core business logic in CLI definitions + +### Core Layer (`core/`) +- `exporter.py`: Main export orchestration +- `parallel_scanner.py`: Parallel scan implementation +- `query_optimizer.py`: Query optimization and index selection +- `multi_query_executor.py`: Multi-query execution +- No Click dependencies + +### Utils Layer (`utils/`) +- `filter_builder.py`: Filter expression parsing and building +- `templates.py`: Template save/load functionality +- `utils.py`: General utility functions diff --git a/cli_tool/dynamodb/__init__.py b/cli_tool/dynamodb/__init__.py index 6ea17cd..28896c5 100644 --- a/cli_tool/dynamodb/__init__.py +++ b/cli_tool/dynamodb/__init__.py @@ -1 +1,5 @@ """DynamoDB export functionality.""" + +from cli_tool.dynamodb.commands.cli import dynamodb + +__all__ = ["dynamodb"] diff --git a/cli_tool/dynamodb/commands/cli.py b/cli_tool/dynamodb/commands/cli.py new file mode 100644 index 0000000..b837d44 --- /dev/null +++ b/cli_tool/dynamodb/commands/cli.py @@ -0,0 +1,266 @@ +"""DynamoDB CLI command group.""" + +from typing import Optional + +import click + +from cli_tool.dynamodb.commands import ( + describe_table_command, + export_table_command, + list_tables_command, + list_templates_command, +) + + +@click.group(name="dynamodb") +@click.pass_context +def dynamodb(ctx): + """DynamoDB utilities for table management and data export.""" + pass + + +@dynamodb.command(name="list") +@click.option( + "--region", + "-r", + default="us-east-1", + help="AWS region (default: us-east-1)", +) +@click.pass_context +def list_tables(ctx, region: str): + """List all DynamoDB tables in the region.""" + from cli_tool.utils.aws import select_profile + + profile = select_profile(ctx.obj.get("profile")) + list_tables_command(profile, region) + + +@dynamodb.command(name="describe") +@click.argument("table_name") +@click.option( + "--region", + "-r", + default="us-east-1", + help="AWS region (default: us-east-1)", +) +@click.pass_context +def describe_table(ctx, table_name: str, region: str): + """Show detailed information about a table.""" + from cli_tool.utils.aws import select_profile + + profile = select_profile(ctx.obj.get("profile")) + describe_table_command(profile, table_name, region) + + +@dynamodb.command(name="export") +@click.argument("table_name") +@click.option( + "--output", + "-o", + type=click.Path(), + help="Output file path (default: _.csv)", +) +@click.option( + "--format", + "-f", + type=click.Choice(["csv", "json", "jsonl", "tsv"], case_sensitive=False), + default="csv", + help="Output format (default: csv)", +) +@click.option( + "--region", + "-r", + default="us-east-1", + help="AWS region (default: us-east-1)", +) +@click.option( + "--limit", + "-l", + type=int, + help="Maximum number of items to export", +) +@click.option( + "--attributes", + "-a", + help="Comma-separated list of attributes to export (ProjectionExpression)", +) +@click.option( + "--filter", + help="Filter expression for scan/query (auto-detects indexes for optimization)", +) +@click.option( + "--filter-values", + help='[Advanced] Expression attribute values as JSON (e.g., \'{":val": "active"}\')', +) +@click.option( + "--filter-names", + help='[Advanced] Expression attribute names as JSON (e.g., \'{"#status": "status"}\')', +) +@click.option( + "--key-condition", + help="[Advanced] Manual key condition expression (auto-detected from --filter in most cases)", +) +@click.option( + "--index", + help="[Advanced] Force specific GSI/LSI (auto-selected from --filter in most cases)", +) +@click.option( + "--mode", + "-m", + type=click.Choice(["strings", "flatten", "normalize"], case_sensitive=False), + default="strings", + help="Export mode: strings (serialize as JSON), flatten (flatten nested), normalize (expand lists to rows)", +) +@click.option( + "--null-value", + default="", + help="Value to use for NULL fields in CSV (default: empty string)", +) +@click.option( + "--delimiter", + default=",", + help="CSV delimiter (default: comma)", +) +@click.option( + "--encoding", + default="utf-8", + help="File encoding (default: utf-8)", +) +@click.option( + "--bool-format", + type=click.Choice(["lowercase", "uppercase", "numeric", "letter"], case_sensitive=False), + default="lowercase", + help="Boolean format: lowercase (true/false), uppercase (True/False), numeric (1/0), letter (t/f) - default: lowercase", +) +@click.option( + "--compress", + type=click.Choice(["gzip", "zip"], case_sensitive=False), + help="Compress output file", +) +@click.option( + "--metadata", + is_flag=True, + help="Include metadata header in CSV output", +) +@click.option( + "--pretty", + is_flag=True, + help="Pretty print JSON output (ignored for JSONL)", +) +@click.option( + "--parallel-scan", + is_flag=True, + help="Use parallel scan for faster export (experimental)", +) +@click.option( + "--segments", + type=int, + default=4, + help="Number of parallel scan segments (default: 4)", +) +@click.option( + "--dry-run", + is_flag=True, + help="Show what would be exported without actually exporting", +) +@click.option( + "--yes", + "-y", + is_flag=True, + help="Skip confirmation prompts", +) +@click.option( + "--save-template", + help="Save current configuration as a template", +) +@click.option( + "--use-template", + help="Use saved template configuration", +) +@click.pass_context +def export_table( + ctx, + table_name: str, + output: Optional[str], + format: str, + region: str, + limit: Optional[int], + attributes: Optional[str], + filter: Optional[str], + filter_values: Optional[str], + filter_names: Optional[str], + key_condition: Optional[str], + index: Optional[str], + mode: str, + null_value: str, + delimiter: str, + encoding: str, + bool_format: str, + compress: Optional[str], + metadata: bool, + pretty: bool, + parallel_scan: bool, + segments: int, + dry_run: bool, + yes: bool, + save_template: Optional[str], + use_template: Optional[str], +): + """ + Export DynamoDB table to CSV, JSON, or JSONL format. + + Examples: + + # Export entire table to CSV + devo dynamodb export my-table + + # Export with filter (auto-detects indexes and optimizes query) + devo dynamodb export my-table --filter "userId = user123" + + # Export with specific attributes + devo dynamodb export my-table -a "id,name,email" --filter "status = active" + + # Export to JSON with compression + devo dynamodb export my-table -f json --compress gzip + + # Advanced: Manual key condition (rarely needed, auto-detected from --filter) + devo dynamodb export my-table --key-condition "userId = :uid" --filter-values '{":uid": "user123"}' + """ + from cli_tool.utils.aws import select_profile + + profile = select_profile(ctx.obj.get("profile")) + export_table_command( + profile=profile, + table_name=table_name, + output=output, + format=format, + region=region, + limit=limit, + attributes=attributes, + filter=filter, + filter_values=filter_values, + filter_names=filter_names, + key_condition=key_condition, + index=index, + mode=mode, + null_value=null_value, + delimiter=delimiter, + encoding=encoding, + bool_format=bool_format, + compress=compress, + metadata=metadata, + pretty=pretty, + parallel_scan=parallel_scan, + segments=segments, + dry_run=dry_run, + yes=yes, + save_template=save_template, + use_template=use_template, + ) + + +@dynamodb.command(name="list-templates") +@click.pass_context +def list_templates_cmd(ctx): + """List all saved export templates.""" + list_templates_command() From cf35933e6c271feddffd6f09bec86b0c87c3ed57 Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Mon, 2 Mar 2026 01:16:45 -0500 Subject: [PATCH 12/37] refactor(cli): eliminate thin wrapper layer in cli_tool/commands/ - Remove all thin wrapper files from cli_tool/commands/ directory - Import commands directly from feature modules in cli.py - Update code organization documentation to reflect new structure - Simplify import paths for aws_login, autocomplete, code_reviewer, dynamodb, ssm, and upgrade - Eliminates unnecessary indirection and follows industry standard for large Python CLI projects --- .kiro/steering/code-organization.md | 17 +++--- cli_tool/cli.py | 12 ++--- cli_tool/commands/__init__.py | 0 cli_tool/commands/aws_login.py | 5 -- cli_tool/commands/code_reviewer.py | 5 -- cli_tool/commands/codeartifact_login.py | 8 --- cli_tool/commands/commit_prompt.py | 8 --- cli_tool/commands/completion.py | 5 -- cli_tool/commands/config.py | 12 ----- cli_tool/commands/dynamodb.py | 5 -- cli_tool/commands/eventbridge.py | 12 ----- cli_tool/commands/ssm.py | 25 --------- cli_tool/commands/upgrade.py | 5 -- cli_tool/ssm/__init__.py | 26 ++++++++- devo.spec | 52 +++++++++++------- docs/commands/code-reviewer.md | 2 +- docs/commands/codeartifact.md | 2 +- docs/commands/commit.md | 2 +- docs/commands/upgrade.md | 2 +- tests/test_commit_prompt.py | 70 ++++++++++++------------- 20 files changed, 113 insertions(+), 162 deletions(-) delete mode 100755 cli_tool/commands/__init__.py delete mode 100644 cli_tool/commands/aws_login.py delete mode 100644 cli_tool/commands/code_reviewer.py delete mode 100644 cli_tool/commands/codeartifact_login.py delete mode 100644 cli_tool/commands/commit_prompt.py delete mode 100755 cli_tool/commands/completion.py delete mode 100644 cli_tool/commands/config.py delete mode 100644 cli_tool/commands/dynamodb.py delete mode 100644 cli_tool/commands/eventbridge.py delete mode 100644 cli_tool/commands/ssm.py delete mode 100644 cli_tool/commands/upgrade.py diff --git a/.kiro/steering/code-organization.md b/.kiro/steering/code-organization.md index 570cf53..0765e04 100644 --- a/.kiro/steering/code-organization.md +++ b/.kiro/steering/code-organization.md @@ -12,7 +12,7 @@ All commands in Devo CLI must follow this standardized structure for consistency ``` cli_tool/feature_name/ -├── __init__.py # Public API exports +├── __init__.py # Public API exports (includes CLI command) ├── README.md # Feature documentation (optional) ├── commands/ # CLI command definitions │ ├── __init__.py # Registers all command groups @@ -35,9 +35,6 @@ cli_tool/feature_name/ └── utils/ # Feature-specific utilities (optional) ├── __init__.py └── helpers.py # Helper functions - -cli_tool/commands/ -└── feature_name.py # Thin wrapper that imports from cli_tool/feature_name/ ``` **Key Principles:** @@ -45,6 +42,7 @@ cli_tool/commands/ - Commands grouped in subdirectories by domain - Shortcuts/aliases in separate file - All feature code contained within feature directory +- CLI command exported from `__init__.py` for direct import in `cli.py` **Examples:** `ssm/`, `dynamodb/`, `code_reviewer/` @@ -196,11 +194,14 @@ None - All features have been migrated! 9. ✅ **DynamoDB** - COMPLETED (moved CLI logic to commands/cli.py) 10. ✅ **Code Reviewer** - COMPLETED (reorganized with commands/, core/) -### Phase 2: New Features -All new features must follow the standard structure from day one. +### Phase 2: Remove cli_tool/commands/ ✅ COMPLETED +- Eliminated thin wrapper layer +- Commands now imported directly from feature modules in `cli.py` +- Follows industry standard for large Python CLI projects +- Cleaner, more direct architecture -### Phase 3: Maintenance ✅ COMPLETED -All features now follow the standard structure with thin wrappers in `cli_tool/commands/`. +### Phase 3: New Features +All new features must follow the standard structure from day one. ## Examples diff --git a/cli_tool/cli.py b/cli_tool/cli.py index c1a94f8..ae717f3 100644 --- a/cli_tool/cli.py +++ b/cli_tool/cli.py @@ -3,16 +3,16 @@ import click from rich.console import Console +from cli_tool.autocomplete import autocomplete +from cli_tool.aws_login import aws_login +from cli_tool.code_reviewer.commands.analyze import code_reviewer from cli_tool.codeartifact import codeartifact_login -from cli_tool.commands.aws_login import aws_login -from cli_tool.commands.code_reviewer import code_reviewer -from cli_tool.commands.completion import autocomplete -from cli_tool.commands.dynamodb import dynamodb -from cli_tool.commands.ssm import ssm -from cli_tool.commands.upgrade import upgrade from cli_tool.commit import commit from cli_tool.config_cmd import register_config_commands +from cli_tool.dynamodb import dynamodb from cli_tool.eventbridge import register_eventbridge_commands +from cli_tool.ssm import ssm +from cli_tool.upgrade import upgrade console = Console() diff --git a/cli_tool/commands/__init__.py b/cli_tool/commands/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/cli_tool/commands/aws_login.py b/cli_tool/commands/aws_login.py deleted file mode 100644 index 2ae47fb..0000000 --- a/cli_tool/commands/aws_login.py +++ /dev/null @@ -1,5 +0,0 @@ -"""AWS SSO Login command - imports from aws_login module.""" - -from cli_tool.aws_login import aws_login - -__all__ = ["aws_login"] diff --git a/cli_tool/commands/code_reviewer.py b/cli_tool/commands/code_reviewer.py deleted file mode 100644 index 4ea1715..0000000 --- a/cli_tool/commands/code_reviewer.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Thin wrapper for code-reviewer command - imports from cli_tool/code_reviewer/.""" - -from cli_tool.code_reviewer.commands.analyze import code_reviewer - -__all__ = ["code_reviewer"] diff --git a/cli_tool/commands/codeartifact_login.py b/cli_tool/commands/codeartifact_login.py deleted file mode 100644 index 066b453..0000000 --- a/cli_tool/commands/codeartifact_login.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Thin wrapper for backward compatibility. - -DEPRECATED: Import from cli_tool.codeartifact instead. -""" - -from cli_tool.codeartifact import codeartifact_login - -__all__ = ["codeartifact_login"] diff --git a/cli_tool/commands/commit_prompt.py b/cli_tool/commands/commit_prompt.py deleted file mode 100644 index 6885049..0000000 --- a/cli_tool/commands/commit_prompt.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Thin wrapper for backward compatibility. - -DEPRECATED: Import from cli_tool.commit instead. -""" - -from cli_tool.commit import commit - -__all__ = ["commit"] diff --git a/cli_tool/commands/completion.py b/cli_tool/commands/completion.py deleted file mode 100755 index e738552..0000000 --- a/cli_tool/commands/completion.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Thin wrapper for autocomplete command - imports from cli_tool.autocomplete.""" - -from cli_tool.autocomplete import autocomplete - -__all__ = ["autocomplete"] diff --git a/cli_tool/commands/config.py b/cli_tool/commands/config.py deleted file mode 100644 index 822e1ca..0000000 --- a/cli_tool/commands/config.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Configuration command wrapper. - -This is a thin wrapper that imports from the main config_cmd module. -All logic is in cli_tool/config_cmd/. -""" - -from cli_tool.config_cmd import register_config_commands - -# For backward compatibility -config_command = register_config_commands() - -__all__ = ["config_command"] diff --git a/cli_tool/commands/dynamodb.py b/cli_tool/commands/dynamodb.py deleted file mode 100644 index 5a764ab..0000000 --- a/cli_tool/commands/dynamodb.py +++ /dev/null @@ -1,5 +0,0 @@ -"""DynamoDB command wrapper - imports from cli_tool.dynamodb.""" - -from cli_tool.dynamodb import dynamodb - -__all__ = ["dynamodb"] diff --git a/cli_tool/commands/eventbridge.py b/cli_tool/commands/eventbridge.py deleted file mode 100644 index 1d21b2b..0000000 --- a/cli_tool/commands/eventbridge.py +++ /dev/null @@ -1,12 +0,0 @@ -"""EventBridge command wrapper. - -This is a thin wrapper that imports from the main eventbridge module. -All logic is in cli_tool/eventbridge/. -""" - -from cli_tool.eventbridge import register_eventbridge_commands - -# For backward compatibility -eventbridge = register_eventbridge_commands() - -__all__ = ["eventbridge"] diff --git a/cli_tool/commands/ssm.py b/cli_tool/commands/ssm.py deleted file mode 100644 index 5ed9285..0000000 --- a/cli_tool/commands/ssm.py +++ /dev/null @@ -1,25 +0,0 @@ -"""AWS Systems Manager Session Manager commands""" - -import click - -from cli_tool.ssm.commands import ( - register_database_commands, - register_forward_command, - register_hosts_commands, - register_instance_commands, - register_shortcuts, -) - - -@click.group() -def ssm(): - """AWS Systems Manager Session Manager commands""" - pass - - -# Register all subcommands -register_database_commands(ssm) -register_instance_commands(ssm) -register_forward_command(ssm) -register_hosts_commands(ssm) -register_shortcuts(ssm) diff --git a/cli_tool/commands/upgrade.py b/cli_tool/commands/upgrade.py deleted file mode 100644 index 07f1741..0000000 --- a/cli_tool/commands/upgrade.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Upgrade command - imports from upgrade module.""" - -from cli_tool.upgrade import upgrade - -__all__ = ["upgrade"] diff --git a/cli_tool/ssm/__init__.py b/cli_tool/ssm/__init__.py index b7eb515..592704a 100644 --- a/cli_tool/ssm/__init__.py +++ b/cli_tool/ssm/__init__.py @@ -1,5 +1,15 @@ """AWS Systems Manager Session Manager integration""" +import click + +from cli_tool.ssm.commands import ( + register_database_commands, + register_forward_command, + register_hosts_commands, + register_instance_commands, + register_shortcuts, +) + # Backward compatibility imports from cli_tool.ssm.core import PortForwarder, SSMConfigManager, SSMSession from cli_tool.ssm.utils import HostsManager @@ -7,4 +17,18 @@ # Backward compatibility alias SocatManager = PortForwarder -__all__ = ["SSMConfigManager", "SSMSession", "HostsManager", "PortForwarder", "SocatManager"] + +@click.group() +def ssm(): + """AWS Systems Manager Session Manager commands""" + pass + + +# Register all subcommands +register_database_commands(ssm) +register_instance_commands(ssm) +register_forward_command(ssm) +register_hosts_commands(ssm) +register_shortcuts(ssm) + +__all__ = ["ssm", "SSMConfigManager", "SSMSession", "HostsManager", "PortForwarder", "SocatManager"] diff --git a/devo.spec b/devo.spec index 2b26bd2..d51c893 100644 --- a/devo.spec +++ b/devo.spec @@ -14,21 +14,20 @@ hidden_imports = [ 'cli_tool', 'cli_tool.cli', 'cli_tool.config', - 'cli_tool.commands', - 'cli_tool.commands.code_reviewer', - 'cli_tool.commands.codeartifact_login', - 'cli_tool.commands.commit_prompt', - 'cli_tool.commands.completion', - 'cli_tool.commands.config', - 'cli_tool.commands.dynamodb', - 'cli_tool.commands.eventbridge', - 'cli_tool.commands.ssm', - 'cli_tool.commands.upgrade', 'cli_tool.agents', 'cli_tool.agents.base_agent', + 'cli_tool.autocomplete', + 'cli_tool.autocomplete.commands', + 'cli_tool.autocomplete.core', + 'cli_tool.aws_login', + 'cli_tool.aws_login.commands', + 'cli_tool.aws_login.core', 'cli_tool.code_reviewer', - 'cli_tool.code_reviewer.analyzer', - 'cli_tool.code_reviewer.git_utils', + 'cli_tool.code_reviewer.commands', + 'cli_tool.code_reviewer.commands.analyze', + 'cli_tool.code_reviewer.core', + 'cli_tool.code_reviewer.core.analyzer', + 'cli_tool.code_reviewer.core.git_utils', 'cli_tool.code_reviewer.prompt', 'cli_tool.code_reviewer.prompt.analysis_rules', 'cli_tool.code_reviewer.prompt.code_reviewer', @@ -38,8 +37,20 @@ hidden_imports = [ 'cli_tool.code_reviewer.tools', 'cli_tool.code_reviewer.tools.code_analyzer', 'cli_tool.code_reviewer.tools.file_reader', + 'cli_tool.codeartifact', + 'cli_tool.codeartifact.commands', + 'cli_tool.codeartifact.core', + 'cli_tool.commit', + 'cli_tool.commit.commands', + 'cli_tool.commit.commands.generate', + 'cli_tool.commit.core', + 'cli_tool.commit.core.generator', + 'cli_tool.config_cmd', + 'cli_tool.config_cmd.commands', + 'cli_tool.config_cmd.core', 'cli_tool.dynamodb', 'cli_tool.dynamodb.commands', + 'cli_tool.dynamodb.commands.cli', 'cli_tool.dynamodb.commands.describe_table', 'cli_tool.dynamodb.commands.export_table', 'cli_tool.dynamodb.commands.list_tables', @@ -48,19 +59,24 @@ hidden_imports = [ 'cli_tool.dynamodb.core.exporter', 'cli_tool.dynamodb.core.parallel_scanner', 'cli_tool.dynamodb.utils', - 'cli_tool.dynamodb.utils.config_manager', 'cli_tool.dynamodb.utils.filter_builder', + 'cli_tool.dynamodb.utils.templates', 'cli_tool.dynamodb.utils.utils', + 'cli_tool.eventbridge', + 'cli_tool.eventbridge.commands', + 'cli_tool.eventbridge.core', + 'cli_tool.eventbridge.utils', 'cli_tool.ssm', - 'cli_tool.ssm.config', - 'cli_tool.ssm.hosts_manager', - 'cli_tool.ssm.port_forwarder', - 'cli_tool.ssm.session', + 'cli_tool.ssm.commands', + 'cli_tool.ssm.core', + 'cli_tool.ssm.utils', + 'cli_tool.upgrade', + 'cli_tool.upgrade.commands', + 'cli_tool.upgrade.core', 'cli_tool.ui', 'cli_tool.ui.console_ui', 'cli_tool.utils', 'cli_tool.utils.aws', - 'cli_tool.utils.aws_profile', 'cli_tool.utils.config_manager', 'cli_tool.utils.git_utils', 'cli_tool.utils.version_check', diff --git a/docs/commands/code-reviewer.md b/docs/commands/code-reviewer.md index 708b9b1..7563e0f 100644 --- a/docs/commands/code-reviewer.md +++ b/docs/commands/code-reviewer.md @@ -15,7 +15,7 @@ Performs comprehensive analysis of code changes using AWS Bedrock AI. Analyzes s ## Usage ::: mkdocs-click - :module: cli_tool.commands.code_reviewer + :module: cli_tool.code_reviewer.commands.analyze :command: code_reviewer :prog_name: devo :depth: 1 diff --git a/docs/commands/codeartifact.md b/docs/commands/codeartifact.md index 2888157..4300d79 100644 --- a/docs/commands/codeartifact.md +++ b/docs/commands/codeartifact.md @@ -15,7 +15,7 @@ Configures pip to authenticate with AWS CodeArtifact repository. Obtains an auth ## Usage ::: mkdocs-click - :module: cli_tool.commands.codeartifact_login + :module: cli_tool.codeartifact :command: codeartifact_login :prog_name: devo :depth: 1 diff --git a/docs/commands/commit.md b/docs/commands/commit.md index cda1a4b..719ef82 100644 --- a/docs/commands/commit.md +++ b/docs/commands/commit.md @@ -15,7 +15,7 @@ Analyzes staged git changes and generates a properly formatted conventional comm ## Usage ::: mkdocs-click - :module: cli_tool.commands.commit_prompt + :module: cli_tool.commit.commands.generate :command: commit :prog_name: devo :depth: 1 diff --git a/docs/commands/upgrade.md b/docs/commands/upgrade.md index b089b52..2bddcd7 100644 --- a/docs/commands/upgrade.md +++ b/docs/commands/upgrade.md @@ -15,7 +15,7 @@ Automatically downloads and installs the latest version of Devo CLI from the con ## Usage ::: mkdocs-click - :module: cli_tool.commands.upgrade + :module: cli_tool.upgrade.commands.upgrade :command: upgrade :prog_name: devo :depth: 1 diff --git a/tests/test_commit_prompt.py b/tests/test_commit_prompt.py index b6f6b52..996e52f 100644 --- a/tests/test_commit_prompt.py +++ b/tests/test_commit_prompt.py @@ -3,7 +3,7 @@ import pytest from click.testing import CliRunner -from cli_tool.commands.commit_prompt import commit +from cli_tool.commit import commit @pytest.fixture @@ -11,13 +11,13 @@ def runner(): return CliRunner() -@patch("cli_tool.commands.commit_prompt.select_profile") -@patch("cli_tool.commands.commit_prompt.get_staged_diff") -@patch("cli_tool.commands.commit_prompt.get_branch_name") -@patch("cli_tool.commands.commit_prompt.get_remote_url") -@patch("cli_tool.commands.commit_prompt.BaseAgent") -@patch("cli_tool.commands.commit_prompt.subprocess.run") -@patch("cli_tool.commands.commit_prompt.webbrowser.open") +@patch("cli_tool.commit.commands.generate.select_profile") +@patch("cli_tool.commit.commands.generate.get_staged_diff") +@patch("cli_tool.commit.commands.generate.get_branch_name") +@patch("cli_tool.commit.commands.generate.get_remote_url") +@patch("cli_tool.commit.core.generator.BaseAgent") +@patch("cli_tool.commit.commands.generate.subprocess.run") +@patch("cli_tool.commit.commands.generate.webbrowser.open") def test_commit_all_options( mock_webbrowser_open, mock_subprocess_run, @@ -75,11 +75,11 @@ def test_commit_all_options( mock_webbrowser_open.assert_called_once() -@patch("cli_tool.commands.commit_prompt.select_profile") -@patch("cli_tool.commands.commit_prompt.get_staged_diff") -@patch("cli_tool.commands.commit_prompt.get_branch_name") -@patch("cli_tool.commands.commit_prompt.BaseAgent") -@patch("cli_tool.commands.commit_prompt.subprocess.run") +@patch("cli_tool.commit.commands.generate.select_profile") +@patch("cli_tool.commit.commands.generate.get_staged_diff") +@patch("cli_tool.commit.commands.generate.get_branch_name") +@patch("cli_tool.commit.core.generator.BaseAgent") +@patch("cli_tool.commit.commands.generate.subprocess.run") def test_commit_manual_message_with_ticket( mock_subprocess_run, mock_base_agent, @@ -119,11 +119,11 @@ def test_commit_manual_message_with_ticket( assert "My manual commit message" in commit_message -@patch("cli_tool.commands.commit_prompt.select_profile") -@patch("cli_tool.commands.commit_prompt.get_staged_diff") -@patch("cli_tool.commands.commit_prompt.get_branch_name") -@patch("cli_tool.commands.commit_prompt.BaseAgent") -@patch("cli_tool.commands.commit_prompt.subprocess.run") +@patch("cli_tool.commit.commands.generate.select_profile") +@patch("cli_tool.commit.commands.generate.get_staged_diff") +@patch("cli_tool.commit.commands.generate.get_branch_name") +@patch("cli_tool.commit.core.generator.BaseAgent") +@patch("cli_tool.commit.commands.generate.subprocess.run") def test_commit_aws_credentials_error( mock_subprocess_run, mock_base_agent, @@ -154,11 +154,11 @@ def test_commit_aws_credentials_error( assert "❌ No AWS credentials found. Please configure your AWS CLI." in result.output -@patch("cli_tool.commands.commit_prompt.select_profile") -@patch("cli_tool.commands.commit_prompt.get_staged_diff") -@patch("cli_tool.commands.commit_prompt.get_branch_name") -@patch("cli_tool.commands.commit_prompt.BaseAgent") -@patch("cli_tool.commands.commit_prompt.subprocess.run") +@patch("cli_tool.commit.commands.generate.select_profile") +@patch("cli_tool.commit.commands.generate.get_staged_diff") +@patch("cli_tool.commit.commands.generate.get_branch_name") +@patch("cli_tool.commit.core.generator.BaseAgent") +@patch("cli_tool.commit.commands.generate.subprocess.run") def test_commit_general_error( mock_subprocess_run, mock_base_agent, @@ -189,11 +189,11 @@ def test_commit_general_error( assert "❌ Error sending request: Some other error" in result.output -@patch("cli_tool.commands.commit_prompt.select_profile") -@patch("cli_tool.commands.commit_prompt.get_staged_diff") -@patch("cli_tool.commands.commit_prompt.get_branch_name") -@patch("cli_tool.commands.commit_prompt.BaseAgent") -@patch("cli_tool.commands.commit_prompt.subprocess.run") +@patch("cli_tool.commit.commands.generate.select_profile") +@patch("cli_tool.commit.commands.generate.get_staged_diff") +@patch("cli_tool.commit.commands.generate.get_branch_name") +@patch("cli_tool.commit.core.generator.BaseAgent") +@patch("cli_tool.commit.commands.generate.subprocess.run") def test_commit_no_ticket_in_branch( mock_subprocess_run, mock_base_agent, @@ -227,10 +227,10 @@ def test_commit_no_ticket_in_branch( assert "TICKET-" not in result.output -@patch("cli_tool.commands.commit_prompt.select_profile") +@patch("cli_tool.commit.commands.generate.select_profile") def test_commit_no_staged_changes(mock_select_profile, runner): mock_select_profile.return_value = "default" - with patch("cli_tool.commands.commit_prompt.get_staged_diff") as mock_get_staged_diff: + with patch("cli_tool.commit.commands.generate.get_staged_diff") as mock_get_staged_diff: mock_get_staged_diff.return_value = "" result = runner.invoke(commit) @@ -239,11 +239,11 @@ def test_commit_no_staged_changes(mock_select_profile, runner): assert "No staged changes found." in result.output -@patch("cli_tool.commands.commit_prompt.select_profile") -@patch("cli_tool.commands.commit_prompt.get_staged_diff") -@patch("cli_tool.commands.commit_prompt.get_branch_name") -@patch("cli_tool.commands.commit_prompt.BaseAgent") -@patch("cli_tool.commands.commit_prompt.subprocess.run") +@patch("cli_tool.commit.commands.generate.select_profile") +@patch("cli_tool.commit.commands.generate.get_staged_diff") +@patch("cli_tool.commit.commands.generate.get_branch_name") +@patch("cli_tool.commit.core.generator.BaseAgent") +@patch("cli_tool.commit.commands.generate.subprocess.run") def test_commit_structured_output( mock_subprocess_run, mock_base_agent, From 4f7d47903a876ed9b99e2acbaf4ed076b1ebc46e Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Mon, 2 Mar 2026 01:35:54 -0500 Subject: [PATCH 13/37] refactor(cli): consolidate command modules under unified commands directory - Migrate all command modules (autocomplete, aws_login, code_reviewer, codeartifact, commit, config_cmd, dynamodb, eventbridge, ssm, upgrade) into centralized cli_tool/commands structure - Eliminate redundant top-level module directories and thin wrapper layers - Establish consistent commands/core/utils subdirectory pattern across all command modules - Update cli.py to reference consolidated command structure - Add __init__.py to new cli_tool/commands root directory - Update steering documentation to reflect new code organization standards - Simplify project structure for improved maintainability and consistency --- .kiro/steering/code-organization.md | 142 +++++++---- .kiro/steering/product.md | 2 +- .kiro/steering/structure.md | 12 +- cli_tool/autocomplete/commands/__init__.py | 5 - cli_tool/autocomplete/core/__init__.py | 5 - cli_tool/aws_login/commands/__init__.py | 15 -- cli_tool/cli.py | 22 +- cli_tool/code_reviewer/__init__.py | 6 - cli_tool/code_reviewer/commands/__init__.py | 5 - cli_tool/code_reviewer/core/__init__.py | 6 - cli_tool/codeartifact/commands/__init__.py | 5 - cli_tool/codeartifact/core/__init__.py | 5 - cli_tool/commands/__init__.py | 1 + .../{ => commands}/autocomplete/README.md | 0 .../{ => commands}/autocomplete/__init__.py | 2 +- .../autocomplete/commands/__init__.py | 5 + .../autocomplete/commands/autocomplete.py | 2 +- .../commands/autocomplete/core/__init__.py | 5 + .../autocomplete/core/installer.py | 0 cli_tool/{ => commands}/aws_login/README.md | 2 +- cli_tool/{ => commands}/aws_login/__init__.py | 6 +- cli_tool/{ => commands}/aws_login/command.py | 4 +- .../commands/aws_login/commands/__init__.py | 15 ++ .../{ => commands}/aws_login/commands/list.py | 4 +- .../aws_login/commands/login.py | 6 +- .../aws_login/commands/refresh.py | 4 +- .../aws_login/commands/set_default.py | 2 +- .../aws_login/commands/setup.py | 4 +- .../{ => commands}/aws_login/core/__init__.py | 4 +- .../{ => commands}/aws_login/core/config.py | 0 .../aws_login/core/credentials.py | 2 +- .../{ => commands}/code_reviewer/README.md | 2 +- cli_tool/{ => commands}/code_reviewer/TODO.md | 0 cli_tool/commands/code_reviewer/__init__.py | 6 + .../code_reviewer/commands/__init__.py | 5 + .../code_reviewer/commands/analyze.py | 6 +- .../commands/code_reviewer/core/__init__.py | 6 + .../code_reviewer/core/analyzer.py | 10 +- .../code_reviewer/core/git_utils.py | 0 .../code_reviewer/prompt}/__init__.py | 0 .../code_reviewer/prompt/analysis_rules.py | 0 .../code_reviewer/prompt/code_reviewer.py | 0 .../code_reviewer/prompt/output_format.py | 0 .../prompt/security_standards.py | 3 +- .../code_reviewer/prompt/tools_guide.py | 0 .../code_reviewer/tools/README.md | 0 .../code_reviewer/tools/__init__.py | 4 +- .../tools/backup_original/__init__.py | 4 +- .../tools/backup_original/code_analyzer.py | 2 +- .../tools/backup_original/file_reader.py | 2 +- .../code_reviewer/tools/code_analyzer.py | 2 +- .../code_reviewer/tools/file_reader.py | 2 +- .../{ => commands}/codeartifact/__init__.py | 2 +- .../codeartifact/commands/__init__.py | 5 + .../codeartifact/commands/login.py | 8 +- .../commands/codeartifact/core/__init__.py | 5 + .../codeartifact/core/authenticator.py | 0 cli_tool/{ => commands}/commit/__init__.py | 2 +- cli_tool/commands/commit/commands/__init__.py | 5 + .../commit/commands/generate.py | 6 +- cli_tool/commands/commit/core/__init__.py | 5 + .../{ => commands}/commit/core/generator.py | 2 +- cli_tool/commands/config_cmd/__init__.py | 5 + .../config_cmd/commands/__init__.py | 16 +- .../config_cmd/commands/export.py | 2 +- .../config_cmd/commands/import_cmd.py | 2 +- .../config_cmd/commands/migrate.py | 2 +- .../config_cmd/commands/path.py | 2 +- .../config_cmd/commands/reset.py | 2 +- .../config_cmd/commands/sections.py | 4 +- .../{ => commands}/config_cmd/commands/set.py | 2 +- .../config_cmd/commands/show.py | 2 +- cli_tool/commands/config_cmd/core/__init__.py | 5 + .../config_cmd/core/descriptions.py | 0 cli_tool/{ => commands}/dynamodb/README.md | 2 +- cli_tool/{ => commands}/dynamodb/__init__.py | 2 +- .../commands/dynamodb/commands/__init__.py | 13 + .../{ => commands}/dynamodb/commands/cli.py | 8 +- .../dynamodb/commands/describe_table.py | 2 +- .../dynamodb/commands/export_table.py | 6 +- .../dynamodb/commands/list_tables.py | 2 +- .../dynamodb/commands/list_templates.py | 2 +- .../{ => commands}/dynamodb/core/README.md | 6 +- cli_tool/commands/dynamodb/core/__init__.py | 14 ++ .../{ => commands}/dynamodb/core/exporter.py | 0 .../dynamodb/core/multi_query_executor.py | 0 .../dynamodb/core/parallel_scanner.py | 0 .../dynamodb/core/query_optimizer.py | 0 cli_tool/commands/dynamodb/utils/__init__.py | 13 + .../dynamodb/utils/filter_builder.py | 0 .../dynamodb/utils/templates.py | 2 +- .../{ => commands}/dynamodb/utils/utils.py | 0 .../{ => commands}/eventbridge/__init__.py | 2 +- .../eventbridge/commands/__init__.py | 2 +- .../eventbridge/commands/list.py | 6 +- .../commands/eventbridge/core/__init__.py | 5 + .../eventbridge/core/rules_manager.py | 2 +- .../commands/eventbridge/utils/__init__.py | 5 + .../eventbridge/utils/formatters.py | 0 cli_tool/{ => commands}/ssm/__init__.py | 6 +- cli_tool/commands/ssm/commands/__init__.py | 15 ++ .../ssm/commands/database/__init__.py | 21 ++ .../ssm/commands/database/add.py | 12 +- .../commands/ssm/commands/database/connect.py | 229 ++++++++++++++++++ .../commands/ssm/commands/database/list.py | 32 +++ .../commands/ssm/commands/database/remove.py | 20 ++ cli_tool/commands/ssm/commands/forward.py | 44 ++++ .../commands/ssm/commands/hosts/__init__.py | 23 ++ cli_tool/commands/ssm/commands/hosts/add.py | 40 +++ cli_tool/commands/ssm/commands/hosts/clear.py | 21 ++ cli_tool/commands/ssm/commands/hosts/list.py | 30 +++ .../commands/ssm/commands/hosts/remove.py | 29 +++ cli_tool/commands/ssm/commands/hosts/setup.py | 81 +++++++ .../ssm/commands/instance/__init__.py | 21 ++ .../ssm/commands/instance/add.py | 12 +- .../commands/ssm/commands/instance/list.py | 32 +++ .../commands/ssm/commands/instance/remove.py | 20 ++ .../commands/ssm/commands/instance/shell.py | 31 +++ cli_tool/commands/ssm/commands/shortcuts.py | 42 ++++ cli_tool/commands/ssm/core/__init__.py | 7 + cli_tool/{ => commands}/ssm/core/config.py | 2 +- .../{ => commands}/ssm/core/port_forwarder.py | 0 cli_tool/{ => commands}/ssm/core/session.py | 0 cli_tool/commands/ssm/utils/__init__.py | 5 + .../{ => commands}/ssm/utils/hosts_manager.py | 0 cli_tool/commands/upgrade/__init__.py | 5 + cli_tool/commands/upgrade/command.py | 5 + .../commands/upgrade/commands/__init__.py | 5 + .../upgrade/commands/upgrade.py | 10 +- cli_tool/commands/upgrade/core/__init__.py | 17 ++ .../{ => commands}/upgrade/core/downloader.py | 0 .../{ => commands}/upgrade/core/installer.py | 0 .../{ => commands}/upgrade/core/platform.py | 0 .../{ => commands}/upgrade/core/version.py | 0 cli_tool/commit/commands/__init__.py | 5 - cli_tool/commit/core/__init__.py | 5 - cli_tool/config.py | 2 +- cli_tool/config_cmd/__init__.py | 5 - cli_tool/config_cmd/core/__init__.py | 5 - cli_tool/core/__init__.py | 1 + .../prompt => core/agents}/__init__.py | 0 cli_tool/{ => core}/agents/base_agent.py | 4 +- cli_tool/{ => core}/ui/__init__.py | 0 cli_tool/{ => core}/ui/console_ui.py | 0 cli_tool/{ => core}/utils/__init__.py | 0 cli_tool/{ => core}/utils/aws.py | 2 +- cli_tool/{ => core}/utils/aws_profile.py | 2 +- cli_tool/{ => core}/utils/config_manager.py | 0 cli_tool/{ => core}/utils/git_utils.py | 0 cli_tool/{ => core}/utils/version_check.py | 2 +- cli_tool/dynamodb/commands/__init__.py | 13 - cli_tool/dynamodb/core/__init__.py | 14 -- cli_tool/dynamodb/utils/__init__.py | 13 - cli_tool/eventbridge/core/__init__.py | 5 - cli_tool/eventbridge/utils/__init__.py | 5 - cli_tool/ssm/commands/__init__.py | 15 -- cli_tool/ssm/commands/database/__init__.py | 23 -- cli_tool/ssm/commands/database/connect.py | 229 ------------------ cli_tool/ssm/commands/database/list.py | 32 --- cli_tool/ssm/commands/database/remove.py | 20 -- cli_tool/ssm/commands/forward.py | 44 ---- cli_tool/ssm/commands/hosts/__init__.py | 25 -- cli_tool/ssm/commands/hosts/add.py | 40 --- cli_tool/ssm/commands/hosts/clear.py | 21 -- cli_tool/ssm/commands/hosts/list.py | 30 --- cli_tool/ssm/commands/hosts/remove.py | 29 --- cli_tool/ssm/commands/hosts/setup.py | 81 ------- cli_tool/ssm/commands/instance/__init__.py | 23 -- cli_tool/ssm/commands/instance/list.py | 32 --- cli_tool/ssm/commands/instance/remove.py | 20 -- cli_tool/ssm/commands/instance/shell.py | 31 --- cli_tool/ssm/commands/shortcuts.py | 42 ---- cli_tool/ssm/core/__init__.py | 7 - cli_tool/ssm/utils/__init__.py | 5 - cli_tool/upgrade/__init__.py | 5 - cli_tool/upgrade/command.py | 5 - cli_tool/upgrade/commands/__init__.py | 5 - cli_tool/upgrade/core/__init__.py | 17 -- devo.spec | 156 +++++++----- docs/development/contributing.md | 5 +- tests/test_commit_prompt.py | 70 +++--- 181 files changed, 1257 insertions(+), 1185 deletions(-) delete mode 100644 cli_tool/autocomplete/commands/__init__.py delete mode 100644 cli_tool/autocomplete/core/__init__.py delete mode 100644 cli_tool/aws_login/commands/__init__.py delete mode 100755 cli_tool/code_reviewer/__init__.py delete mode 100644 cli_tool/code_reviewer/commands/__init__.py delete mode 100644 cli_tool/code_reviewer/core/__init__.py delete mode 100644 cli_tool/codeartifact/commands/__init__.py delete mode 100644 cli_tool/codeartifact/core/__init__.py create mode 100644 cli_tool/commands/__init__.py rename cli_tool/{ => commands}/autocomplete/README.md (100%) rename cli_tool/{ => commands}/autocomplete/__init__.py (50%) create mode 100644 cli_tool/commands/autocomplete/commands/__init__.py rename cli_tool/{ => commands}/autocomplete/commands/autocomplete.py (97%) create mode 100644 cli_tool/commands/autocomplete/core/__init__.py rename cli_tool/{ => commands}/autocomplete/core/installer.py (100%) rename cli_tool/{ => commands}/aws_login/README.md (99%) rename cli_tool/{ => commands}/aws_login/__init__.py (84%) rename cli_tool/{ => commands}/aws_login/command.py (96%) create mode 100644 cli_tool/commands/aws_login/commands/__init__.py rename cli_tool/{ => commands}/aws_login/commands/list.py (93%) rename cli_tool/{ => commands}/aws_login/commands/login.py (96%) rename cli_tool/{ => commands}/aws_login/commands/refresh.py (96%) rename cli_tool/{ => commands}/aws_login/commands/set_default.py (98%) rename cli_tool/{ => commands}/aws_login/commands/setup.py (99%) rename cli_tool/{ => commands}/aws_login/core/__init__.py (84%) rename cli_tool/{ => commands}/aws_login/core/config.py (100%) rename cli_tool/{ => commands}/aws_login/core/credentials.py (98%) rename cli_tool/{ => commands}/code_reviewer/README.md (98%) rename cli_tool/{ => commands}/code_reviewer/TODO.md (100%) create mode 100755 cli_tool/commands/code_reviewer/__init__.py create mode 100644 cli_tool/commands/code_reviewer/commands/__init__.py rename cli_tool/{ => commands}/code_reviewer/commands/analyze.py (94%) create mode 100644 cli_tool/commands/code_reviewer/core/__init__.py rename cli_tool/{ => commands}/code_reviewer/core/analyzer.py (97%) rename cli_tool/{ => commands}/code_reviewer/core/git_utils.py (100%) rename cli_tool/{agents => commands/code_reviewer/prompt}/__init__.py (100%) rename cli_tool/{ => commands}/code_reviewer/prompt/analysis_rules.py (100%) rename cli_tool/{ => commands}/code_reviewer/prompt/code_reviewer.py (100%) rename cli_tool/{ => commands}/code_reviewer/prompt/output_format.py (100%) rename cli_tool/{ => commands}/code_reviewer/prompt/security_standards.py (97%) rename cli_tool/{ => commands}/code_reviewer/prompt/tools_guide.py (100%) rename cli_tool/{ => commands}/code_reviewer/tools/README.md (100%) rename cli_tool/{ => commands}/code_reviewer/tools/__init__.py (69%) rename cli_tool/{ => commands}/code_reviewer/tools/backup_original/__init__.py (92%) rename cli_tool/{ => commands}/code_reviewer/tools/backup_original/code_analyzer.py (99%) rename cli_tool/{ => commands}/code_reviewer/tools/backup_original/file_reader.py (99%) rename cli_tool/{ => commands}/code_reviewer/tools/code_analyzer.py (99%) rename cli_tool/{ => commands}/code_reviewer/tools/file_reader.py (99%) rename cli_tool/{ => commands}/codeartifact/__init__.py (50%) create mode 100644 cli_tool/commands/codeartifact/commands/__init__.py rename cli_tool/{ => commands}/codeartifact/commands/login.py (96%) create mode 100644 cli_tool/commands/codeartifact/core/__init__.py rename cli_tool/{ => commands}/codeartifact/core/authenticator.py (100%) rename cli_tool/{ => commands}/commit/__init__.py (50%) create mode 100644 cli_tool/commands/commit/commands/__init__.py rename cli_tool/{ => commands}/commit/commands/generate.py (93%) create mode 100644 cli_tool/commands/commit/core/__init__.py rename cli_tool/{ => commands}/commit/core/generator.py (99%) create mode 100644 cli_tool/commands/config_cmd/__init__.py rename cli_tool/{ => commands}/config_cmd/commands/__init__.py (56%) rename cli_tool/{ => commands}/config_cmd/commands/export.py (96%) rename cli_tool/{ => commands}/config_cmd/commands/import_cmd.py (95%) rename cli_tool/{ => commands}/config_cmd/commands/migrate.py (92%) rename cli_tool/{ => commands}/config_cmd/commands/path.py (81%) rename cli_tool/{ => commands}/config_cmd/commands/reset.py (90%) rename cli_tool/{ => commands}/config_cmd/commands/sections.py (78%) rename cli_tool/{ => commands}/config_cmd/commands/set.py (93%) rename cli_tool/{ => commands}/config_cmd/commands/show.py (91%) create mode 100644 cli_tool/commands/config_cmd/core/__init__.py rename cli_tool/{ => commands}/config_cmd/core/descriptions.py (100%) rename cli_tool/{ => commands}/dynamodb/README.md (99%) rename cli_tool/{ => commands}/dynamodb/__init__.py (50%) create mode 100644 cli_tool/commands/dynamodb/commands/__init__.py rename cli_tool/{ => commands}/dynamodb/commands/cli.py (96%) rename cli_tool/{ => commands}/dynamodb/commands/describe_table.py (99%) rename cli_tool/{ => commands}/dynamodb/commands/export_table.py (99%) rename cli_tool/{ => commands}/dynamodb/commands/list_tables.py (98%) rename cli_tool/{ => commands}/dynamodb/commands/list_templates.py (75%) rename cli_tool/{ => commands}/dynamodb/core/README.md (88%) create mode 100644 cli_tool/commands/dynamodb/core/__init__.py rename cli_tool/{ => commands}/dynamodb/core/exporter.py (100%) rename cli_tool/{ => commands}/dynamodb/core/multi_query_executor.py (100%) rename cli_tool/{ => commands}/dynamodb/core/parallel_scanner.py (100%) rename cli_tool/{ => commands}/dynamodb/core/query_optimizer.py (100%) create mode 100644 cli_tool/commands/dynamodb/utils/__init__.py rename cli_tool/{ => commands}/dynamodb/utils/filter_builder.py (100%) rename cli_tool/{ => commands}/dynamodb/utils/templates.py (98%) rename cli_tool/{ => commands}/dynamodb/utils/utils.py (100%) rename cli_tool/{ => commands}/eventbridge/__init__.py (50%) rename cli_tool/{ => commands}/eventbridge/commands/__init__.py (93%) rename cli_tool/{ => commands}/eventbridge/commands/list.py (86%) create mode 100644 cli_tool/commands/eventbridge/core/__init__.py rename cli_tool/{ => commands}/eventbridge/core/rules_manager.py (98%) create mode 100644 cli_tool/commands/eventbridge/utils/__init__.py rename cli_tool/{ => commands}/eventbridge/utils/formatters.py (100%) rename cli_tool/{ => commands}/ssm/__init__.py (79%) create mode 100644 cli_tool/commands/ssm/commands/__init__.py create mode 100644 cli_tool/commands/ssm/commands/database/__init__.py rename cli_tool/{ => commands}/ssm/commands/database/add.py (62%) create mode 100644 cli_tool/commands/ssm/commands/database/connect.py create mode 100644 cli_tool/commands/ssm/commands/database/list.py create mode 100644 cli_tool/commands/ssm/commands/database/remove.py create mode 100644 cli_tool/commands/ssm/commands/forward.py create mode 100644 cli_tool/commands/ssm/commands/hosts/__init__.py create mode 100644 cli_tool/commands/ssm/commands/hosts/add.py create mode 100644 cli_tool/commands/ssm/commands/hosts/clear.py create mode 100644 cli_tool/commands/ssm/commands/hosts/list.py create mode 100644 cli_tool/commands/ssm/commands/hosts/remove.py create mode 100644 cli_tool/commands/ssm/commands/hosts/setup.py create mode 100644 cli_tool/commands/ssm/commands/instance/__init__.py rename cli_tool/{ => commands}/ssm/commands/instance/add.py (53%) create mode 100644 cli_tool/commands/ssm/commands/instance/list.py create mode 100644 cli_tool/commands/ssm/commands/instance/remove.py create mode 100644 cli_tool/commands/ssm/commands/instance/shell.py create mode 100644 cli_tool/commands/ssm/commands/shortcuts.py create mode 100644 cli_tool/commands/ssm/core/__init__.py rename cli_tool/{ => commands}/ssm/core/config.py (98%) rename cli_tool/{ => commands}/ssm/core/port_forwarder.py (100%) rename cli_tool/{ => commands}/ssm/core/session.py (100%) create mode 100644 cli_tool/commands/ssm/utils/__init__.py rename cli_tool/{ => commands}/ssm/utils/hosts_manager.py (100%) create mode 100644 cli_tool/commands/upgrade/__init__.py create mode 100644 cli_tool/commands/upgrade/command.py create mode 100644 cli_tool/commands/upgrade/commands/__init__.py rename cli_tool/{ => commands}/upgrade/commands/upgrade.py (93%) create mode 100644 cli_tool/commands/upgrade/core/__init__.py rename cli_tool/{ => commands}/upgrade/core/downloader.py (100%) rename cli_tool/{ => commands}/upgrade/core/installer.py (100%) rename cli_tool/{ => commands}/upgrade/core/platform.py (100%) rename cli_tool/{ => commands}/upgrade/core/version.py (100%) delete mode 100644 cli_tool/commit/commands/__init__.py delete mode 100644 cli_tool/commit/core/__init__.py delete mode 100644 cli_tool/config_cmd/__init__.py delete mode 100644 cli_tool/config_cmd/core/__init__.py create mode 100644 cli_tool/core/__init__.py rename cli_tool/{code_reviewer/prompt => core/agents}/__init__.py (100%) rename cli_tool/{ => core}/agents/base_agent.py (99%) rename cli_tool/{ => core}/ui/__init__.py (100%) rename cli_tool/{ => core}/ui/console_ui.py (100%) rename cli_tool/{ => core}/utils/__init__.py (100%) rename cli_tool/{ => core}/utils/aws.py (99%) rename cli_tool/{ => core}/utils/aws_profile.py (99%) rename cli_tool/{ => core}/utils/config_manager.py (100%) rename cli_tool/{ => core}/utils/git_utils.py (100%) rename cli_tool/{ => core}/utils/version_check.py (98%) delete mode 100644 cli_tool/dynamodb/commands/__init__.py delete mode 100644 cli_tool/dynamodb/core/__init__.py delete mode 100644 cli_tool/dynamodb/utils/__init__.py delete mode 100644 cli_tool/eventbridge/core/__init__.py delete mode 100644 cli_tool/eventbridge/utils/__init__.py delete mode 100644 cli_tool/ssm/commands/__init__.py delete mode 100644 cli_tool/ssm/commands/database/__init__.py delete mode 100644 cli_tool/ssm/commands/database/connect.py delete mode 100644 cli_tool/ssm/commands/database/list.py delete mode 100644 cli_tool/ssm/commands/database/remove.py delete mode 100644 cli_tool/ssm/commands/forward.py delete mode 100644 cli_tool/ssm/commands/hosts/__init__.py delete mode 100644 cli_tool/ssm/commands/hosts/add.py delete mode 100644 cli_tool/ssm/commands/hosts/clear.py delete mode 100644 cli_tool/ssm/commands/hosts/list.py delete mode 100644 cli_tool/ssm/commands/hosts/remove.py delete mode 100644 cli_tool/ssm/commands/hosts/setup.py delete mode 100644 cli_tool/ssm/commands/instance/__init__.py delete mode 100644 cli_tool/ssm/commands/instance/list.py delete mode 100644 cli_tool/ssm/commands/instance/remove.py delete mode 100644 cli_tool/ssm/commands/instance/shell.py delete mode 100644 cli_tool/ssm/commands/shortcuts.py delete mode 100644 cli_tool/ssm/core/__init__.py delete mode 100644 cli_tool/ssm/utils/__init__.py delete mode 100644 cli_tool/upgrade/__init__.py delete mode 100644 cli_tool/upgrade/command.py delete mode 100644 cli_tool/upgrade/commands/__init__.py delete mode 100644 cli_tool/upgrade/core/__init__.py diff --git a/.kiro/steering/code-organization.md b/.kiro/steering/code-organization.md index 0765e04..7c2152a 100644 --- a/.kiro/steering/code-organization.md +++ b/.kiro/steering/code-organization.md @@ -1,5 +1,45 @@ # Code Organization Standard +## Project Structure Overview + +The Devo CLI project follows a clean, scalable architecture that separates commands from core infrastructure: + +``` +cli_tool/ +├── __init__.py +├── _version.py # Auto-generated from git tags +├── cli.py # CLI entry point +├── config.py # Global configuration +│ +├── commands/ # All feature commands +│ ├── __init__.py +│ ├── aws_login/ +│ ├── autocomplete/ +│ ├── code_reviewer/ +│ ├── codeartifact/ +│ ├── commit/ +│ ├── config_cmd/ +│ ├── dynamodb/ +│ ├── eventbridge/ +│ ├── ssm/ +│ └── upgrade/ +│ +└── core/ # Core infrastructure (shared) + ├── __init__.py + ├── agents/ # AI agent framework + ├── ui/ # Rich UI components + └── utils/ # Shared utilities +``` + +## Key Benefits + +1. **Clear Separation**: Commands vs Core infrastructure +2. **Scalability**: Easy to add new commands without cluttering root +3. **Navigation**: All features in one place (`commands/`) +4. **Import Clarity**: + - Commands: `from cli_tool.commands.ssm import ssm` + - Core: `from cli_tool.core.agents import BaseAgent` + ## Command Structure All commands in Devo CLI must follow this standardized structure for consistency and maintainability. @@ -11,7 +51,7 @@ All commands in Devo CLI must follow this standardized structure for consistency **ALL commands MUST follow this structure, regardless of size or complexity:** ``` -cli_tool/feature_name/ +cli_tool/commands/feature_name/ ├── __init__.py # Public API exports (includes CLI command) ├── README.md # Feature documentation (optional) ├── commands/ # CLI command definitions @@ -44,7 +84,7 @@ cli_tool/feature_name/ - All feature code contained within feature directory - CLI command exported from `__init__.py` for direct import in `cli.py` -**Examples:** `ssm/`, `dynamodb/`, `code_reviewer/` +**Examples:** `commands/ssm/`, `commands/dynamodb/`, `commands/code_reviewer/` **No exceptions:** Even single-command features use this structure for consistency. @@ -113,7 +153,7 @@ def register_resource_commands(parent_group): All configuration must use the centralized config manager: ```python -from cli_tool.utils.config_manager import load_config, save_config +from cli_tool.core.utils.config_manager import load_config, save_config # Read config config = load_config() @@ -126,7 +166,7 @@ save_config(config) ### Feature-Specific Config Helpers -Create helper functions in `cli_tool/utils/config_manager.py`: +Create helper functions in `cli_tool/core/utils/config_manager.py`: ```python def get_feature_config() -> Dict: @@ -143,62 +183,61 @@ def save_feature_config(feature_config: Dict): ## Separation of Concerns -### Commands Layer (`cli_tool/commands/` or `cli_tool/feature/commands/`) +### Commands Layer (`cli_tool/commands/feature/commands/`) - Click decorators and CLI interface - User input validation - Output formatting with Rich - Error handling and user messages - **NO business logic** -### Core Layer (`cli_tool/feature/core/`) +### Core Layer (`cli_tool/commands/feature/core/`) - Business logic - Data processing - API calls - **NO Click dependencies** - **NO Rich console output** (return data, let commands format) -### Utils Layer (`cli_tool/feature/utils/`) +### Utils Layer (`cli_tool/commands/feature/utils/`) - Helper functions - Data transformations - Validators - **Reusable across commands** -## Current State vs Standard - -### ✅ Follows Standard -- `cli_tool/ssm/` - Reference implementation with commands/, core/, utils/ -- `cli_tool/dynamodb/` - Well organized with commands/, core/, utils/ -- `cli_tool/code_reviewer/` - Good separation with commands/, core/, prompt/, tools/ -- `cli_tool/eventbridge/` - Organized with commands/, core/, utils/ -- `cli_tool/config_cmd/` - Organized with commands/, core/ -- `cli_tool/aws_login/` - Reorganized with commands/, core/ -- `cli_tool/upgrade/` - Reorganized with core/ (single command, no commands/ needed) -- `cli_tool/autocomplete/` - Reorganized with commands/, core/ -- `cli_tool/codeartifact/` - Reorganized with commands/, core/ -- `cli_tool/commit/` - Reorganized with commands/, core/ - -### ⚠️ Needs Refactoring -None - All features have been migrated! - -## Migration Plan - -### Phase 1: Standardize Existing Features ✅ COMPLETED -1. ✅ **SSM** - COMPLETED -2. ✅ **AWS Login** - COMPLETED -3. ✅ **Upgrade** - COMPLETED -4. ✅ **Autocomplete** - COMPLETED (renamed from completion) -5. ✅ **CodeArtifact** - COMPLETED -6. ✅ **Commit** - COMPLETED -7. ✅ **EventBridge** - COMPLETED (already had proper structure) -8. ✅ **Config** - COMPLETED (already had proper structure) -9. ✅ **DynamoDB** - COMPLETED (moved CLI logic to commands/cli.py) -10. ✅ **Code Reviewer** - COMPLETED (reorganized with commands/, core/) - -### Phase 2: Remove cli_tool/commands/ ✅ COMPLETED -- Eliminated thin wrapper layer -- Commands now imported directly from feature modules in `cli.py` -- Follows industry standard for large Python CLI projects -- Cleaner, more direct architecture +### Shared Core (`cli_tool/core/`) +- `agents/` - AI agent framework (BaseAgent) +- `ui/` - Rich UI components (console_ui) +- `utils/` - Shared utilities (config_manager, aws, git_utils) + +## Current State + +### ✅ All Features Migrated +- `cli_tool/commands/ssm/` - Reference implementation with commands/, core/, utils/ +- `cli_tool/commands/dynamodb/` - Well organized with commands/, core/, utils/ +- `cli_tool/commands/code_reviewer/` - Good separation with commands/, core/, prompt/, tools/ +- `cli_tool/commands/eventbridge/` - Organized with commands/, core/, utils/ +- `cli_tool/commands/config_cmd/` - Organized with commands/, core/ +- `cli_tool/commands/aws_login/` - Reorganized with commands/, core/ +- `cli_tool/commands/upgrade/` - Reorganized with commands/, core/ +- `cli_tool/commands/autocomplete/` - Reorganized with commands/, core/ +- `cli_tool/commands/codeartifact/` - Reorganized with commands/, core/ +- `cli_tool/commands/commit/` - Reorganized with commands/, core/ + +### ✅ Core Infrastructure +- `cli_tool/core/agents/` - AI agent framework +- `cli_tool/core/ui/` - Rich UI components +- `cli_tool/core/utils/` - Shared utilities + +## Migration Completed + +### Phase 1: Standardize Features ✅ COMPLETED +All features migrated to standardized structure within their directories. + +### Phase 2: Top-Level Reorganization ✅ COMPLETED +- Created `cli_tool/commands/` - All feature commands +- Created `cli_tool/core/` - Shared infrastructure +- Moved all features to `commands/` +- Moved agents, ui, utils to `core/` +- Updated all imports across the codebase ### Phase 3: New Features All new features must follow the standard structure from day one. @@ -207,7 +246,7 @@ All new features must follow the standard structure from day one. ### ✅ Good: SSM Structure (Reference Implementation) ``` -cli_tool/ssm/ +cli_tool/commands/ssm/ ├── __init__.py ├── commands/ │ ├── __init__.py @@ -244,7 +283,7 @@ cli_tool/ssm/ ### ✅ Good: DynamoDB Structure ``` -cli_tool/dynamodb/ +cli_tool/commands/dynamodb/ ├── __init__.py ├── commands/ │ ├── __init__.py @@ -260,6 +299,17 @@ cli_tool/dynamodb/ └── filter_builder.py # Query building ``` +### ❌ Bad: Mixed Structure at Root Level +``` +cli_tool/ +├── ssm/ # Feature +├── dynamodb/ # Feature +├── agents/ # Infrastructure +├── ui/ # Infrastructure +├── utils/ # Infrastructure +└── cli.py # Entry point +``` + ### ❌ Bad: Large Single File ``` cli_tool/commands/ @@ -268,7 +318,7 @@ cli_tool/commands/ ### ❌ Bad: Missing Subdirectories for Command Groups ``` -cli_tool/ssm/commands/ +cli_tool/commands/ssm/commands/ ├── database_connect.py # Should be database/connect.py ├── database_list.py # Should be database/list.py ├── instance_shell.py # Should be instance/shell.py diff --git a/.kiro/steering/product.md b/.kiro/steering/product.md index aa25459..4d55f02 100644 --- a/.kiro/steering/product.md +++ b/.kiro/steering/product.md @@ -68,7 +68,7 @@ Self-updates the CLI tool to the latest version. - Prompts for complex features organized in `prompt/` subdirectories ### AI Agent Pattern -- All AI features extend `BaseAgent` from `cli_tool/agents/base_agent.py` +- All AI features extend `BaseAgent` from `cli_tool/core/agents/base_agent.py` - System prompts defined inline or in dedicated prompt modules - Structured outputs using Pydantic models for type safety diff --git a/.kiro/steering/structure.md b/.kiro/steering/structure.md index f6fd4f6..41dcf4e 100644 --- a/.kiro/steering/structure.md +++ b/.kiro/steering/structure.md @@ -12,9 +12,9 @@ All source code lives in `cli_tool/` package: - `cli_tool/config.py` - AWS Bedrock models, paths, configuration - `cli_tool/_version.py` - Auto-generated from git tags (NEVER edit manually) - `cli_tool/commands/` - Each CLI command as separate module -- `cli_tool/agents/` - AI agent base classes (BaseAgent wrapper) -- `cli_tool/ui/` - Rich-based terminal UI components -- `cli_tool/utils/` - Shared utilities (AWS, git) +- `cli_tool/core/agents/` - AI agent base classes (BaseAgent wrapper) +- `cli_tool/core/ui/` - Rich-based terminal UI components +- `cli_tool/core/utils/` - Shared utilities (AWS, git) ## Architecture Patterns @@ -25,17 +25,17 @@ All source code lives in `cli_tool/` package: 4. Follow existing command structure for consistency ### AI Agent Pattern -- Extend `BaseAgent` from `cli_tool/agents/base_agent.py` +- Extend `BaseAgent` from `cli_tool/core/agents/base_agent.py` - Use Pydantic models for structured outputs - Define system prompts inline or in dedicated prompt modules - For complex features, create subdirectory with `prompt/` folder (see `code_reviewer/`) ### Feature Modules -Large features get their own subdirectory under `cli_tool/`: +Large features get their own subdirectory under `cli_tool/commands/`: - Main logic files (e.g., `analyzer.py`, `git_utils.py`) - `prompt/` subdirectory for AI prompts - `tools/` subdirectory for feature-specific tools -- Example: `cli_tool/code_reviewer/` +- Example: `cli_tool/commands/code_reviewer/` ## Naming Conventions (REQUIRED) diff --git a/cli_tool/autocomplete/commands/__init__.py b/cli_tool/autocomplete/commands/__init__.py deleted file mode 100644 index 5fc7b01..0000000 --- a/cli_tool/autocomplete/commands/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Autocomplete commands.""" - -from cli_tool.autocomplete.commands.autocomplete import autocomplete - -__all__ = ["autocomplete"] diff --git a/cli_tool/autocomplete/core/__init__.py b/cli_tool/autocomplete/core/__init__.py deleted file mode 100644 index 11e1084..0000000 --- a/cli_tool/autocomplete/core/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Core autocomplete logic.""" - -from cli_tool.autocomplete.core.installer import CompletionInstaller - -__all__ = ["CompletionInstaller"] diff --git a/cli_tool/aws_login/commands/__init__.py b/cli_tool/aws_login/commands/__init__.py deleted file mode 100644 index 20ec84a..0000000 --- a/cli_tool/aws_login/commands/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""AWS Login commands.""" - -from cli_tool.aws_login.commands.list import list_profiles -from cli_tool.aws_login.commands.login import perform_login -from cli_tool.aws_login.commands.refresh import refresh_all_profiles -from cli_tool.aws_login.commands.set_default import set_default_profile -from cli_tool.aws_login.commands.setup import configure_sso_profile - -__all__ = [ - "list_profiles", - "perform_login", - "refresh_all_profiles", - "set_default_profile", - "configure_sso_profile", -] diff --git a/cli_tool/cli.py b/cli_tool/cli.py index ae717f3..fd0bc88 100644 --- a/cli_tool/cli.py +++ b/cli_tool/cli.py @@ -3,16 +3,16 @@ import click from rich.console import Console -from cli_tool.autocomplete import autocomplete -from cli_tool.aws_login import aws_login -from cli_tool.code_reviewer.commands.analyze import code_reviewer -from cli_tool.codeartifact import codeartifact_login -from cli_tool.commit import commit -from cli_tool.config_cmd import register_config_commands -from cli_tool.dynamodb import dynamodb -from cli_tool.eventbridge import register_eventbridge_commands -from cli_tool.ssm import ssm -from cli_tool.upgrade import upgrade +from cli_tool.commands.autocomplete import autocomplete +from cli_tool.commands.aws_login import aws_login +from cli_tool.commands.code_reviewer.commands.analyze import code_reviewer +from cli_tool.commands.codeartifact import codeartifact_login +from cli_tool.commands.commit import commit +from cli_tool.commands.config_cmd import register_config_commands +from cli_tool.commands.dynamodb import dynamodb +from cli_tool.commands.eventbridge import register_eventbridge_commands +from cli_tool.commands.ssm import ssm +from cli_tool.commands.upgrade import upgrade console = Console() @@ -117,7 +117,7 @@ def main(): cli(obj={}) finally: # Show update notification after command execution - from cli_tool.utils.version_check import show_update_notification + from cli_tool.core.utils.version_check import show_update_notification show_update_notification() diff --git a/cli_tool/code_reviewer/__init__.py b/cli_tool/code_reviewer/__init__.py deleted file mode 100755 index 794ea08..0000000 --- a/cli_tool/code_reviewer/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Code Reviewer - AI-Powered Code Analysis.""" - -from cli_tool.code_reviewer.core.analyzer import CodeReviewAnalyzer -from cli_tool.code_reviewer.core.git_utils import GitManager - -__all__ = ["CodeReviewAnalyzer", "GitManager"] diff --git a/cli_tool/code_reviewer/commands/__init__.py b/cli_tool/code_reviewer/commands/__init__.py deleted file mode 100644 index 9c590bf..0000000 --- a/cli_tool/code_reviewer/commands/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Code Reviewer commands.""" - -from cli_tool.code_reviewer.commands.analyze import code_reviewer - -__all__ = ["code_reviewer"] diff --git a/cli_tool/code_reviewer/core/__init__.py b/cli_tool/code_reviewer/core/__init__.py deleted file mode 100644 index 7e14848..0000000 --- a/cli_tool/code_reviewer/core/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Code Reviewer core business logic.""" - -from cli_tool.code_reviewer.core.analyzer import CodeReviewAnalyzer -from cli_tool.code_reviewer.core.git_utils import GitManager - -__all__ = ["CodeReviewAnalyzer", "GitManager"] diff --git a/cli_tool/codeartifact/commands/__init__.py b/cli_tool/codeartifact/commands/__init__.py deleted file mode 100644 index edcd862..0000000 --- a/cli_tool/codeartifact/commands/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""CodeArtifact CLI commands.""" - -from cli_tool.codeartifact.commands.login import codeartifact_login - -__all__ = ["codeartifact_login"] diff --git a/cli_tool/codeartifact/core/__init__.py b/cli_tool/codeartifact/core/__init__.py deleted file mode 100644 index 038a946..0000000 --- a/cli_tool/codeartifact/core/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""CodeArtifact core business logic.""" - -from cli_tool.codeartifact.core.authenticator import CodeArtifactAuthenticator - -__all__ = ["CodeArtifactAuthenticator"] diff --git a/cli_tool/commands/__init__.py b/cli_tool/commands/__init__.py new file mode 100644 index 0000000..3f4c467 --- /dev/null +++ b/cli_tool/commands/__init__.py @@ -0,0 +1 @@ +"""CLI commands package.""" diff --git a/cli_tool/autocomplete/README.md b/cli_tool/commands/autocomplete/README.md similarity index 100% rename from cli_tool/autocomplete/README.md rename to cli_tool/commands/autocomplete/README.md diff --git a/cli_tool/autocomplete/__init__.py b/cli_tool/commands/autocomplete/__init__.py similarity index 50% rename from cli_tool/autocomplete/__init__.py rename to cli_tool/commands/autocomplete/__init__.py index 24bff2f..ef622e3 100644 --- a/cli_tool/autocomplete/__init__.py +++ b/cli_tool/commands/autocomplete/__init__.py @@ -1,5 +1,5 @@ """Shell autocomplete management for Devo CLI.""" -from cli_tool.autocomplete.commands.autocomplete import autocomplete +from cli_tool.commands.autocomplete.commands.autocomplete import autocomplete __all__ = ["autocomplete"] diff --git a/cli_tool/commands/autocomplete/commands/__init__.py b/cli_tool/commands/autocomplete/commands/__init__.py new file mode 100644 index 0000000..b74c840 --- /dev/null +++ b/cli_tool/commands/autocomplete/commands/__init__.py @@ -0,0 +1,5 @@ +"""Autocomplete commands.""" + +from cli_tool.commands.autocomplete.commands.autocomplete import autocomplete + +__all__ = ["autocomplete"] diff --git a/cli_tool/autocomplete/commands/autocomplete.py b/cli_tool/commands/autocomplete/commands/autocomplete.py similarity index 97% rename from cli_tool/autocomplete/commands/autocomplete.py rename to cli_tool/commands/autocomplete/commands/autocomplete.py index b4d12fb..ec3fc7e 100644 --- a/cli_tool/autocomplete/commands/autocomplete.py +++ b/cli_tool/commands/autocomplete/commands/autocomplete.py @@ -4,7 +4,7 @@ import click -from cli_tool.autocomplete.core import CompletionInstaller +from cli_tool.commands.autocomplete.core import CompletionInstaller @click.command() diff --git a/cli_tool/commands/autocomplete/core/__init__.py b/cli_tool/commands/autocomplete/core/__init__.py new file mode 100644 index 0000000..c3ff3c8 --- /dev/null +++ b/cli_tool/commands/autocomplete/core/__init__.py @@ -0,0 +1,5 @@ +"""Core autocomplete logic.""" + +from cli_tool.commands.autocomplete.core.installer import CompletionInstaller + +__all__ = ["CompletionInstaller"] diff --git a/cli_tool/autocomplete/core/installer.py b/cli_tool/commands/autocomplete/core/installer.py similarity index 100% rename from cli_tool/autocomplete/core/installer.py rename to cli_tool/commands/autocomplete/core/installer.py diff --git a/cli_tool/aws_login/README.md b/cli_tool/commands/aws_login/README.md similarity index 99% rename from cli_tool/aws_login/README.md rename to cli_tool/commands/aws_login/README.md index 1c0b8e1..6854649 100644 --- a/cli_tool/aws_login/README.md +++ b/cli_tool/commands/aws_login/README.md @@ -5,7 +5,7 @@ Modular implementation of the AWS SSO login command with subcommands. ## Module Structure ``` -cli_tool/aws_login/ +cli_tool/commands/aws_login/ ├── __init__.py # Module exports ├── command.py # Main Click group entry point ├── commands/ # Command implementations diff --git a/cli_tool/aws_login/__init__.py b/cli_tool/commands/aws_login/__init__.py similarity index 84% rename from cli_tool/aws_login/__init__.py rename to cli_tool/commands/aws_login/__init__.py index 3fc5703..48c7d08 100644 --- a/cli_tool/aws_login/__init__.py +++ b/cli_tool/commands/aws_login/__init__.py @@ -1,14 +1,14 @@ """AWS SSO Login module.""" -from cli_tool.aws_login.command import aws_login -from cli_tool.aws_login.commands import ( +from cli_tool.commands.aws_login.command import aws_login +from cli_tool.commands.aws_login.commands import ( configure_sso_profile, list_profiles, perform_login, refresh_all_profiles, set_default_profile, ) -from cli_tool.aws_login.core import ( +from cli_tool.commands.aws_login.core import ( check_profile_needs_refresh, get_aws_config_path, get_aws_credentials_path, diff --git a/cli_tool/aws_login/command.py b/cli_tool/commands/aws_login/command.py similarity index 96% rename from cli_tool/aws_login/command.py rename to cli_tool/commands/aws_login/command.py index a16e3b7..c327cdb 100644 --- a/cli_tool/aws_login/command.py +++ b/cli_tool/commands/aws_login/command.py @@ -5,14 +5,14 @@ import click from rich.console import Console -from cli_tool.aws_login.commands import ( +from cli_tool.commands.aws_login.commands import ( configure_sso_profile, list_profiles, perform_login, refresh_all_profiles, set_default_profile, ) -from cli_tool.utils.aws import check_aws_cli +from cli_tool.core.utils.aws import check_aws_cli console = Console() diff --git a/cli_tool/commands/aws_login/commands/__init__.py b/cli_tool/commands/aws_login/commands/__init__.py new file mode 100644 index 0000000..28a0c7f --- /dev/null +++ b/cli_tool/commands/aws_login/commands/__init__.py @@ -0,0 +1,15 @@ +"""AWS Login commands.""" + +from cli_tool.commands.aws_login.commands.list import list_profiles +from cli_tool.commands.aws_login.commands.login import perform_login +from cli_tool.commands.aws_login.commands.refresh import refresh_all_profiles +from cli_tool.commands.aws_login.commands.set_default import set_default_profile +from cli_tool.commands.aws_login.commands.setup import configure_sso_profile + +__all__ = [ + "list_profiles", + "perform_login", + "refresh_all_profiles", + "set_default_profile", + "configure_sso_profile", +] diff --git a/cli_tool/aws_login/commands/list.py b/cli_tool/commands/aws_login/commands/list.py similarity index 93% rename from cli_tool/aws_login/commands/list.py rename to cli_tool/commands/aws_login/commands/list.py index 8d653bc..8de1ba4 100644 --- a/cli_tool/aws_login/commands/list.py +++ b/cli_tool/commands/aws_login/commands/list.py @@ -6,8 +6,8 @@ from rich.console import Console from rich.table import Table -from cli_tool.aws_login.core.config import get_profile_config, list_aws_profiles -from cli_tool.aws_login.core.credentials import get_profile_credentials_expiration +from cli_tool.commands.aws_login.core.config import get_profile_config, list_aws_profiles +from cli_tool.commands.aws_login.core.credentials import get_profile_credentials_expiration console = Console() diff --git a/cli_tool/aws_login/commands/login.py b/cli_tool/commands/aws_login/commands/login.py similarity index 96% rename from cli_tool/aws_login/commands/login.py rename to cli_tool/commands/aws_login/commands/login.py index 2b8589a..07169af 100644 --- a/cli_tool/aws_login/commands/login.py +++ b/cli_tool/commands/aws_login/commands/login.py @@ -7,9 +7,9 @@ import click from rich.console import Console -from cli_tool.aws_login.commands.setup import configure_sso_profile -from cli_tool.aws_login.core.config import list_aws_profiles, parse_sso_config -from cli_tool.aws_login.core.credentials import ( +from cli_tool.commands.aws_login.commands.setup import configure_sso_profile +from cli_tool.commands.aws_login.core.config import list_aws_profiles, parse_sso_config +from cli_tool.commands.aws_login.core.credentials import ( get_profile_credentials_expiration, verify_credentials, ) diff --git a/cli_tool/aws_login/commands/refresh.py b/cli_tool/commands/aws_login/commands/refresh.py similarity index 96% rename from cli_tool/aws_login/commands/refresh.py rename to cli_tool/commands/aws_login/commands/refresh.py index 22701ef..42d280f 100644 --- a/cli_tool/aws_login/commands/refresh.py +++ b/cli_tool/commands/aws_login/commands/refresh.py @@ -8,8 +8,8 @@ from rich.console import Console from rich.table import Table -from cli_tool.aws_login.core.config import get_profile_config, list_aws_profiles -from cli_tool.aws_login.core.credentials import ( +from cli_tool.commands.aws_login.core.config import get_profile_config, list_aws_profiles +from cli_tool.commands.aws_login.core.credentials import ( check_profile_needs_refresh, verify_credentials, ) diff --git a/cli_tool/aws_login/commands/set_default.py b/cli_tool/commands/aws_login/commands/set_default.py similarity index 98% rename from cli_tool/aws_login/commands/set_default.py rename to cli_tool/commands/aws_login/commands/set_default.py index 6d34a8f..70e3450 100644 --- a/cli_tool/aws_login/commands/set_default.py +++ b/cli_tool/commands/aws_login/commands/set_default.py @@ -8,7 +8,7 @@ import click from rich.console import Console -from cli_tool.aws_login.core.config import list_aws_profiles +from cli_tool.commands.aws_login.core.config import list_aws_profiles console = Console() diff --git a/cli_tool/aws_login/commands/setup.py b/cli_tool/commands/aws_login/commands/setup.py similarity index 99% rename from cli_tool/aws_login/commands/setup.py rename to cli_tool/commands/aws_login/commands/setup.py index e195fe9..9efaa78 100644 --- a/cli_tool/aws_login/commands/setup.py +++ b/cli_tool/commands/aws_login/commands/setup.py @@ -6,12 +6,12 @@ import click from rich.console import Console -from cli_tool.aws_login.core.config import ( +from cli_tool.commands.aws_login.core.config import ( get_aws_config_path, get_existing_sso_sessions, get_profile_config, ) -from cli_tool.aws_login.core.credentials import get_sso_cache_token +from cli_tool.commands.aws_login.core.credentials import get_sso_cache_token console = Console() diff --git a/cli_tool/aws_login/core/__init__.py b/cli_tool/commands/aws_login/core/__init__.py similarity index 84% rename from cli_tool/aws_login/core/__init__.py rename to cli_tool/commands/aws_login/core/__init__.py index 866a1fc..e8f2f97 100644 --- a/cli_tool/aws_login/core/__init__.py +++ b/cli_tool/commands/aws_login/core/__init__.py @@ -1,6 +1,6 @@ """AWS Login core functionality.""" -from cli_tool.aws_login.core.config import ( +from cli_tool.commands.aws_login.core.config import ( get_aws_config_path, get_aws_credentials_path, get_existing_sso_sessions, @@ -8,7 +8,7 @@ list_aws_profiles, parse_sso_config, ) -from cli_tool.aws_login.core.credentials import ( +from cli_tool.commands.aws_login.core.credentials import ( check_profile_needs_refresh, get_profile_credentials_expiration, get_sso_cache_token, diff --git a/cli_tool/aws_login/core/config.py b/cli_tool/commands/aws_login/core/config.py similarity index 100% rename from cli_tool/aws_login/core/config.py rename to cli_tool/commands/aws_login/core/config.py diff --git a/cli_tool/aws_login/core/credentials.py b/cli_tool/commands/aws_login/core/credentials.py similarity index 98% rename from cli_tool/aws_login/core/credentials.py rename to cli_tool/commands/aws_login/core/credentials.py index e7b1023..3454dcc 100644 --- a/cli_tool/aws_login/core/credentials.py +++ b/cli_tool/commands/aws_login/core/credentials.py @@ -7,7 +7,7 @@ from rich.console import Console -from cli_tool.aws_login.core.config import get_profile_config +from cli_tool.commands.aws_login.core.config import get_profile_config console = Console() diff --git a/cli_tool/code_reviewer/README.md b/cli_tool/commands/code_reviewer/README.md similarity index 98% rename from cli_tool/code_reviewer/README.md rename to cli_tool/commands/code_reviewer/README.md index 545367e..2456e5d 100644 --- a/cli_tool/code_reviewer/README.md +++ b/cli_tool/commands/code_reviewer/README.md @@ -5,7 +5,7 @@ AI-Powered Code Analysis for Pull Requests using AWS Bedrock. ## Structure ``` -cli_tool/code_reviewer/ +cli_tool/commands/code_reviewer/ ├── __init__.py # Public API exports ├── README.md # This file ├── TODO.md # Feature roadmap diff --git a/cli_tool/code_reviewer/TODO.md b/cli_tool/commands/code_reviewer/TODO.md similarity index 100% rename from cli_tool/code_reviewer/TODO.md rename to cli_tool/commands/code_reviewer/TODO.md diff --git a/cli_tool/commands/code_reviewer/__init__.py b/cli_tool/commands/code_reviewer/__init__.py new file mode 100755 index 0000000..5d17aa3 --- /dev/null +++ b/cli_tool/commands/code_reviewer/__init__.py @@ -0,0 +1,6 @@ +"""Code Reviewer - AI-Powered Code Analysis.""" + +from cli_tool.commands.code_reviewer.core.analyzer import CodeReviewAnalyzer +from cli_tool.commands.code_reviewer.core.git_utils import GitManager + +__all__ = ["CodeReviewAnalyzer", "GitManager"] diff --git a/cli_tool/commands/code_reviewer/commands/__init__.py b/cli_tool/commands/code_reviewer/commands/__init__.py new file mode 100644 index 0000000..6d9a775 --- /dev/null +++ b/cli_tool/commands/code_reviewer/commands/__init__.py @@ -0,0 +1,5 @@ +"""Code Reviewer commands.""" + +from cli_tool.commands.code_reviewer.commands.analyze import code_reviewer + +__all__ = ["code_reviewer"] diff --git a/cli_tool/code_reviewer/commands/analyze.py b/cli_tool/commands/code_reviewer/commands/analyze.py similarity index 94% rename from cli_tool/code_reviewer/commands/analyze.py rename to cli_tool/commands/code_reviewer/commands/analyze.py index 02d09e1..60b1103 100644 --- a/cli_tool/code_reviewer/commands/analyze.py +++ b/cli_tool/commands/code_reviewer/commands/analyze.py @@ -6,8 +6,8 @@ import click -from cli_tool.code_reviewer.core.analyzer import CodeReviewAnalyzer -from cli_tool.ui.console_ui import console_ui +from cli_tool.commands.code_reviewer.core.analyzer import CodeReviewAnalyzer +from cli_tool.core.ui.console_ui import console_ui @click.command(name="code-reviewer") @@ -83,7 +83,7 @@ def code_reviewer( devo --profile my-profile code-reviewer """ try: - from cli_tool.utils.aws import select_profile + from cli_tool.core.utils.aws import select_profile profile = select_profile(ctx.obj.get("profile")) analyzer = CodeReviewAnalyzer(profile_name=profile) diff --git a/cli_tool/commands/code_reviewer/core/__init__.py b/cli_tool/commands/code_reviewer/core/__init__.py new file mode 100644 index 0000000..ba6ec10 --- /dev/null +++ b/cli_tool/commands/code_reviewer/core/__init__.py @@ -0,0 +1,6 @@ +"""Code Reviewer core business logic.""" + +from cli_tool.commands.code_reviewer.core.analyzer import CodeReviewAnalyzer +from cli_tool.commands.code_reviewer.core.git_utils import GitManager + +__all__ = ["CodeReviewAnalyzer", "GitManager"] diff --git a/cli_tool/code_reviewer/core/analyzer.py b/cli_tool/commands/code_reviewer/core/analyzer.py similarity index 97% rename from cli_tool/code_reviewer/core/analyzer.py rename to cli_tool/commands/code_reviewer/core/analyzer.py index efdcab6..48f33c9 100644 --- a/cli_tool/code_reviewer/core/analyzer.py +++ b/cli_tool/commands/code_reviewer/core/analyzer.py @@ -7,20 +7,20 @@ from pathlib import Path from typing import Dict, Optional -from cli_tool.agents.base_agent import BaseAgent -from cli_tool.code_reviewer.core.git_utils import GitManager -from cli_tool.code_reviewer.prompt.code_reviewer import ( +from cli_tool.commands.code_reviewer.core.git_utils import GitManager +from cli_tool.commands.code_reviewer.prompt.code_reviewer import ( CODE_REVIEWER_PROMPT, CODE_REVIEWER_PROMPT_SHORT, ) -from cli_tool.code_reviewer.tools import ( +from cli_tool.commands.code_reviewer.tools import ( analyze_import_usage, get_file_content, get_file_info, search_code_references, search_function_definition, ) -from cli_tool.ui.console_ui import console_ui +from cli_tool.core.agents.base_agent import BaseAgent +from cli_tool.core.ui.console_ui import console_ui class CodeReviewAnalyzer: diff --git a/cli_tool/code_reviewer/core/git_utils.py b/cli_tool/commands/code_reviewer/core/git_utils.py similarity index 100% rename from cli_tool/code_reviewer/core/git_utils.py rename to cli_tool/commands/code_reviewer/core/git_utils.py diff --git a/cli_tool/agents/__init__.py b/cli_tool/commands/code_reviewer/prompt/__init__.py similarity index 100% rename from cli_tool/agents/__init__.py rename to cli_tool/commands/code_reviewer/prompt/__init__.py diff --git a/cli_tool/code_reviewer/prompt/analysis_rules.py b/cli_tool/commands/code_reviewer/prompt/analysis_rules.py similarity index 100% rename from cli_tool/code_reviewer/prompt/analysis_rules.py rename to cli_tool/commands/code_reviewer/prompt/analysis_rules.py diff --git a/cli_tool/code_reviewer/prompt/code_reviewer.py b/cli_tool/commands/code_reviewer/prompt/code_reviewer.py similarity index 100% rename from cli_tool/code_reviewer/prompt/code_reviewer.py rename to cli_tool/commands/code_reviewer/prompt/code_reviewer.py diff --git a/cli_tool/code_reviewer/prompt/output_format.py b/cli_tool/commands/code_reviewer/prompt/output_format.py similarity index 100% rename from cli_tool/code_reviewer/prompt/output_format.py rename to cli_tool/commands/code_reviewer/prompt/output_format.py diff --git a/cli_tool/code_reviewer/prompt/security_standards.py b/cli_tool/commands/code_reviewer/prompt/security_standards.py similarity index 97% rename from cli_tool/code_reviewer/prompt/security_standards.py rename to cli_tool/commands/code_reviewer/prompt/security_standards.py index 431acfe..da9827f 100755 --- a/cli_tool/code_reviewer/prompt/security_standards.py +++ b/cli_tool/commands/code_reviewer/prompt/security_standards.py @@ -7,7 +7,8 @@ Apply these frameworks when analyzing code changes: **Key Standards:** -- **OWASP Top 10**: Injection, broken auth, data exposure, XXE, access control, misconfiguration, XSS, deserialization, vulnerable components, insufficient logging +- **OWASP Top 10**: Injection, broken auth, data exposure, XXE, access control, misconfiguration, XSS, deserialization, vulnerable components, + insufficient logging - **CWE Top 25**: Focus on injection (CWE-79, 89, 78, 94), memory corruption (CWE-787, 125, 119), access control (CWE-22, 352, 434) - **Language-specific**: Java deserialization, Python pickle/eval, JS prototype pollution, C/C++ buffer overflows diff --git a/cli_tool/code_reviewer/prompt/tools_guide.py b/cli_tool/commands/code_reviewer/prompt/tools_guide.py similarity index 100% rename from cli_tool/code_reviewer/prompt/tools_guide.py rename to cli_tool/commands/code_reviewer/prompt/tools_guide.py diff --git a/cli_tool/code_reviewer/tools/README.md b/cli_tool/commands/code_reviewer/tools/README.md similarity index 100% rename from cli_tool/code_reviewer/tools/README.md rename to cli_tool/commands/code_reviewer/tools/README.md diff --git a/cli_tool/code_reviewer/tools/__init__.py b/cli_tool/commands/code_reviewer/tools/__init__.py similarity index 69% rename from cli_tool/code_reviewer/tools/__init__.py rename to cli_tool/commands/code_reviewer/tools/__init__.py index ac2574f..cc4b64a 100755 --- a/cli_tool/code_reviewer/tools/__init__.py +++ b/cli_tool/commands/code_reviewer/tools/__init__.py @@ -2,14 +2,14 @@ Optimized tools module with reduced documentation. """ -from cli_tool.code_reviewer.tools.code_analyzer import ( +from cli_tool.commands.code_reviewer.tools.code_analyzer import ( analyze_import_usage, search_code_references, search_function_definition, ) # Import tools -from cli_tool.code_reviewer.tools.file_reader import get_file_content, get_file_info +from cli_tool.commands.code_reviewer.tools.file_reader import get_file_content, get_file_info __all__ = [ # File operations diff --git a/cli_tool/code_reviewer/tools/backup_original/__init__.py b/cli_tool/commands/code_reviewer/tools/backup_original/__init__.py similarity index 92% rename from cli_tool/code_reviewer/tools/backup_original/__init__.py rename to cli_tool/commands/code_reviewer/tools/backup_original/__init__.py index 1e567bd..357de89 100755 --- a/cli_tool/code_reviewer/tools/backup_original/__init__.py +++ b/cli_tool/commands/code_reviewer/tools/backup_original/__init__.py @@ -47,14 +47,14 @@ - Git-aware with automatic .gitignore pattern exclusion """ -from cli_tool.code_reviewer.tools.code_analyzer import ( +from cli_tool.commands.code_reviewer.tools.code_analyzer import ( analyze_import_usage, search_code_references, search_function_definition, ) # Import all tools for easy access -from cli_tool.code_reviewer.tools.file_reader import get_file_content, get_file_info +from cli_tool.commands.code_reviewer.tools.file_reader import get_file_content, get_file_info __all__ = [ # File operations diff --git a/cli_tool/code_reviewer/tools/backup_original/code_analyzer.py b/cli_tool/commands/code_reviewer/tools/backup_original/code_analyzer.py similarity index 99% rename from cli_tool/code_reviewer/tools/backup_original/code_analyzer.py rename to cli_tool/commands/code_reviewer/tools/backup_original/code_analyzer.py index efaca96..06b3e3e 100755 --- a/cli_tool/code_reviewer/tools/backup_original/code_analyzer.py +++ b/cli_tool/commands/code_reviewer/tools/backup_original/code_analyzer.py @@ -45,7 +45,7 @@ from strands import tool -from cli_tool.ui.console_ui import console_ui +from cli_tool.core.ui.console_ui import console_ui def get_smart_search_patterns(symbol_name: str) -> List[str]: diff --git a/cli_tool/code_reviewer/tools/backup_original/file_reader.py b/cli_tool/commands/code_reviewer/tools/backup_original/file_reader.py similarity index 99% rename from cli_tool/code_reviewer/tools/backup_original/file_reader.py rename to cli_tool/commands/code_reviewer/tools/backup_original/file_reader.py index ff4fae4..41df549 100755 --- a/cli_tool/code_reviewer/tools/backup_original/file_reader.py +++ b/cli_tool/commands/code_reviewer/tools/backup_original/file_reader.py @@ -76,7 +76,7 @@ from strands import tool -from cli_tool.ui.console_ui import console_ui +from cli_tool.core.ui.console_ui import console_ui def get_gitignore_excludes() -> List[str]: diff --git a/cli_tool/code_reviewer/tools/code_analyzer.py b/cli_tool/commands/code_reviewer/tools/code_analyzer.py similarity index 99% rename from cli_tool/code_reviewer/tools/code_analyzer.py rename to cli_tool/commands/code_reviewer/tools/code_analyzer.py index f5ab813..57776bf 100644 --- a/cli_tool/code_reviewer/tools/code_analyzer.py +++ b/cli_tool/commands/code_reviewer/tools/code_analyzer.py @@ -10,7 +10,7 @@ from strands import tool -from cli_tool.ui.console_ui import console_ui +from cli_tool.core.ui.console_ui import console_ui def get_smart_search_patterns(symbol_name: str) -> List[str]: diff --git a/cli_tool/code_reviewer/tools/file_reader.py b/cli_tool/commands/code_reviewer/tools/file_reader.py similarity index 99% rename from cli_tool/code_reviewer/tools/file_reader.py rename to cli_tool/commands/code_reviewer/tools/file_reader.py index 88b7460..5445583 100644 --- a/cli_tool/code_reviewer/tools/file_reader.py +++ b/cli_tool/commands/code_reviewer/tools/file_reader.py @@ -10,7 +10,7 @@ from strands import tool -from cli_tool.ui.console_ui import console_ui +from cli_tool.core.ui.console_ui import console_ui def get_gitignore_excludes() -> List[str]: diff --git a/cli_tool/codeartifact/__init__.py b/cli_tool/commands/codeartifact/__init__.py similarity index 50% rename from cli_tool/codeartifact/__init__.py rename to cli_tool/commands/codeartifact/__init__.py index 37d0e9e..d0e9f8c 100644 --- a/cli_tool/codeartifact/__init__.py +++ b/cli_tool/commands/codeartifact/__init__.py @@ -1,5 +1,5 @@ """CodeArtifact authentication module.""" -from cli_tool.codeartifact.commands.login import codeartifact_login +from cli_tool.commands.codeartifact.commands.login import codeartifact_login __all__ = ["codeartifact_login"] diff --git a/cli_tool/commands/codeartifact/commands/__init__.py b/cli_tool/commands/codeartifact/commands/__init__.py new file mode 100644 index 0000000..e2c01a3 --- /dev/null +++ b/cli_tool/commands/codeartifact/commands/__init__.py @@ -0,0 +1,5 @@ +"""CodeArtifact CLI commands.""" + +from cli_tool.commands.codeartifact.commands.login import codeartifact_login + +__all__ = ["codeartifact_login"] diff --git a/cli_tool/codeartifact/commands/login.py b/cli_tool/commands/codeartifact/commands/login.py similarity index 96% rename from cli_tool/codeartifact/commands/login.py rename to cli_tool/commands/codeartifact/commands/login.py index 5c29806..1c40c21 100644 --- a/cli_tool/codeartifact/commands/login.py +++ b/cli_tool/commands/codeartifact/commands/login.py @@ -5,14 +5,14 @@ import click from rich.console import Console -from cli_tool.codeartifact.core.authenticator import CodeArtifactAuthenticator +from cli_tool.commands.codeartifact.core.authenticator import CodeArtifactAuthenticator from cli_tool.config import ( AWS_SSO_URL, CODEARTIFACT_DOMAINS, CODEARTIFACT_REGION, ) -from cli_tool.utils.aws import check_aws_cli -from cli_tool.utils.aws_profile import ( +from cli_tool.core.utils.aws import check_aws_cli +from cli_tool.core.utils.aws_profile import ( REQUIRED_ACCOUNT, REQUIRED_ROLE, verify_aws_credentials, @@ -33,7 +33,7 @@ def codeartifact_login(ctx): devo codeartifact-login devo --profile my-profile codeartifact-login """ - from cli_tool.utils.aws import select_profile + from cli_tool.core.utils.aws import select_profile # Get profile from context or prompt user to select profile = select_profile(ctx.obj.get("profile")) diff --git a/cli_tool/commands/codeartifact/core/__init__.py b/cli_tool/commands/codeartifact/core/__init__.py new file mode 100644 index 0000000..04e8a85 --- /dev/null +++ b/cli_tool/commands/codeartifact/core/__init__.py @@ -0,0 +1,5 @@ +"""CodeArtifact core business logic.""" + +from cli_tool.commands.codeartifact.core.authenticator import CodeArtifactAuthenticator + +__all__ = ["CodeArtifactAuthenticator"] diff --git a/cli_tool/codeartifact/core/authenticator.py b/cli_tool/commands/codeartifact/core/authenticator.py similarity index 100% rename from cli_tool/codeartifact/core/authenticator.py rename to cli_tool/commands/codeartifact/core/authenticator.py diff --git a/cli_tool/commit/__init__.py b/cli_tool/commands/commit/__init__.py similarity index 50% rename from cli_tool/commit/__init__.py rename to cli_tool/commands/commit/__init__.py index 89aee87..0173eee 100644 --- a/cli_tool/commit/__init__.py +++ b/cli_tool/commands/commit/__init__.py @@ -1,5 +1,5 @@ """Commit message generation module.""" -from cli_tool.commit.commands.generate import commit +from cli_tool.commands.commit.commands.generate import commit __all__ = ["commit"] diff --git a/cli_tool/commands/commit/commands/__init__.py b/cli_tool/commands/commit/commands/__init__.py new file mode 100644 index 0000000..44cfd5b --- /dev/null +++ b/cli_tool/commands/commit/commands/__init__.py @@ -0,0 +1,5 @@ +"""Commit CLI commands.""" + +from cli_tool.commands.commit.commands.generate import commit + +__all__ = ["commit"] diff --git a/cli_tool/commit/commands/generate.py b/cli_tool/commands/commit/commands/generate.py similarity index 93% rename from cli_tool/commit/commands/generate.py rename to cli_tool/commands/commit/commands/generate.py index 0098042..6c5f0ce 100644 --- a/cli_tool/commit/commands/generate.py +++ b/cli_tool/commands/commit/commands/generate.py @@ -5,9 +5,9 @@ import click -from cli_tool.commit.core.generator import CommitMessageGenerator -from cli_tool.utils.aws import select_profile -from cli_tool.utils.git_utils import get_branch_name, get_remote_url, get_staged_diff +from cli_tool.commands.commit.core.generator import CommitMessageGenerator +from cli_tool.core.utils.aws import select_profile +from cli_tool.core.utils.git_utils import get_branch_name, get_remote_url, get_staged_diff @click.command() diff --git a/cli_tool/commands/commit/core/__init__.py b/cli_tool/commands/commit/core/__init__.py new file mode 100644 index 0000000..1a388a7 --- /dev/null +++ b/cli_tool/commands/commit/core/__init__.py @@ -0,0 +1,5 @@ +"""Commit core business logic.""" + +from cli_tool.commands.commit.core.generator import CommitMessageGenerator + +__all__ = ["CommitMessageGenerator"] diff --git a/cli_tool/commit/core/generator.py b/cli_tool/commands/commit/core/generator.py similarity index 99% rename from cli_tool/commit/core/generator.py rename to cli_tool/commands/commit/core/generator.py index 103cc67..92fbd64 100644 --- a/cli_tool/commit/core/generator.py +++ b/cli_tool/commands/commit/core/generator.py @@ -4,7 +4,7 @@ import subprocess from typing import Optional, Tuple -from cli_tool.agents.base_agent import BaseAgent +from cli_tool.core.agents.base_agent import BaseAgent class CommitMessageGenerator: diff --git a/cli_tool/commands/config_cmd/__init__.py b/cli_tool/commands/config_cmd/__init__.py new file mode 100644 index 0000000..430eab7 --- /dev/null +++ b/cli_tool/commands/config_cmd/__init__.py @@ -0,0 +1,5 @@ +"""Configuration management.""" + +from cli_tool.commands.config_cmd.commands import register_config_commands + +__all__ = ["register_config_commands"] diff --git a/cli_tool/config_cmd/commands/__init__.py b/cli_tool/commands/config_cmd/commands/__init__.py similarity index 56% rename from cli_tool/config_cmd/commands/__init__.py rename to cli_tool/commands/config_cmd/commands/__init__.py index 3810e69..b486f30 100644 --- a/cli_tool/config_cmd/commands/__init__.py +++ b/cli_tool/commands/config_cmd/commands/__init__.py @@ -2,14 +2,14 @@ import click -from cli_tool.config_cmd.commands.export import export_command -from cli_tool.config_cmd.commands.import_cmd import import_command -from cli_tool.config_cmd.commands.migrate import migrate_command -from cli_tool.config_cmd.commands.path import show_path -from cli_tool.config_cmd.commands.reset import reset_command -from cli_tool.config_cmd.commands.sections import list_sections -from cli_tool.config_cmd.commands.set import set_command -from cli_tool.config_cmd.commands.show import show_config +from cli_tool.commands.config_cmd.commands.export import export_command +from cli_tool.commands.config_cmd.commands.import_cmd import import_command +from cli_tool.commands.config_cmd.commands.migrate import migrate_command +from cli_tool.commands.config_cmd.commands.path import show_path +from cli_tool.commands.config_cmd.commands.reset import reset_command +from cli_tool.commands.config_cmd.commands.sections import list_sections +from cli_tool.commands.config_cmd.commands.set import set_command +from cli_tool.commands.config_cmd.commands.show import show_config def register_config_commands(): diff --git a/cli_tool/config_cmd/commands/export.py b/cli_tool/commands/config_cmd/commands/export.py similarity index 96% rename from cli_tool/config_cmd/commands/export.py rename to cli_tool/commands/config_cmd/commands/export.py index b0831db..6383978 100644 --- a/cli_tool/config_cmd/commands/export.py +++ b/cli_tool/commands/config_cmd/commands/export.py @@ -6,7 +6,7 @@ import click from rich.console import Console -from cli_tool.utils.config_manager import export_config +from cli_tool.core.utils.config_manager import export_config console = Console() diff --git a/cli_tool/config_cmd/commands/import_cmd.py b/cli_tool/commands/config_cmd/commands/import_cmd.py similarity index 95% rename from cli_tool/config_cmd/commands/import_cmd.py rename to cli_tool/commands/config_cmd/commands/import_cmd.py index e9a8285..b8825ee 100644 --- a/cli_tool/config_cmd/commands/import_cmd.py +++ b/cli_tool/commands/config_cmd/commands/import_cmd.py @@ -3,7 +3,7 @@ import click from rich.console import Console -from cli_tool.utils.config_manager import import_config +from cli_tool.core.utils.config_manager import import_config console = Console() diff --git a/cli_tool/config_cmd/commands/migrate.py b/cli_tool/commands/config_cmd/commands/migrate.py similarity index 92% rename from cli_tool/config_cmd/commands/migrate.py rename to cli_tool/commands/config_cmd/commands/migrate.py index 0d7b62e..6c3ec6b 100644 --- a/cli_tool/config_cmd/commands/migrate.py +++ b/cli_tool/commands/config_cmd/commands/migrate.py @@ -3,7 +3,7 @@ import click from rich.console import Console -from cli_tool.utils.config_manager import migrate_legacy_configs +from cli_tool.core.utils.config_manager import migrate_legacy_configs console = Console() diff --git a/cli_tool/config_cmd/commands/path.py b/cli_tool/commands/config_cmd/commands/path.py similarity index 81% rename from cli_tool/config_cmd/commands/path.py rename to cli_tool/commands/config_cmd/commands/path.py index a44d3f4..a19a1b4 100644 --- a/cli_tool/config_cmd/commands/path.py +++ b/cli_tool/commands/config_cmd/commands/path.py @@ -3,7 +3,7 @@ import click from rich.console import Console -from cli_tool.utils.config_manager import get_config_path +from cli_tool.core.utils.config_manager import get_config_path console = Console() diff --git a/cli_tool/config_cmd/commands/reset.py b/cli_tool/commands/config_cmd/commands/reset.py similarity index 90% rename from cli_tool/config_cmd/commands/reset.py rename to cli_tool/commands/config_cmd/commands/reset.py index 5034d07..b0fd45c 100644 --- a/cli_tool/config_cmd/commands/reset.py +++ b/cli_tool/commands/config_cmd/commands/reset.py @@ -3,7 +3,7 @@ import click from rich.console import Console -from cli_tool.utils.config_manager import get_default_config, reset_config, set_config_value +from cli_tool.core.utils.config_manager import get_default_config, reset_config, set_config_value console = Console() diff --git a/cli_tool/config_cmd/commands/sections.py b/cli_tool/commands/config_cmd/commands/sections.py similarity index 78% rename from cli_tool/config_cmd/commands/sections.py rename to cli_tool/commands/config_cmd/commands/sections.py index f3dc98d..95efb2a 100644 --- a/cli_tool/config_cmd/commands/sections.py +++ b/cli_tool/commands/config_cmd/commands/sections.py @@ -4,8 +4,8 @@ from rich.console import Console from rich.table import Table -from cli_tool.config_cmd.core.descriptions import SECTION_DESCRIPTIONS -from cli_tool.utils.config_manager import list_config_sections +from cli_tool.commands.config_cmd.core.descriptions import SECTION_DESCRIPTIONS +from cli_tool.core.utils.config_manager import list_config_sections console = Console() diff --git a/cli_tool/config_cmd/commands/set.py b/cli_tool/commands/config_cmd/commands/set.py similarity index 93% rename from cli_tool/config_cmd/commands/set.py rename to cli_tool/commands/config_cmd/commands/set.py index 4a2ddc2..77e28b1 100644 --- a/cli_tool/config_cmd/commands/set.py +++ b/cli_tool/commands/config_cmd/commands/set.py @@ -5,7 +5,7 @@ import click from rich.console import Console -from cli_tool.utils.config_manager import set_config_value +from cli_tool.core.utils.config_manager import set_config_value console = Console() diff --git a/cli_tool/config_cmd/commands/show.py b/cli_tool/commands/config_cmd/commands/show.py similarity index 91% rename from cli_tool/config_cmd/commands/show.py rename to cli_tool/commands/config_cmd/commands/show.py index b714733..e14751a 100644 --- a/cli_tool/config_cmd/commands/show.py +++ b/cli_tool/commands/config_cmd/commands/show.py @@ -6,7 +6,7 @@ from rich.console import Console from rich.syntax import Syntax -from cli_tool.utils.config_manager import list_config_sections, load_config +from cli_tool.core.utils.config_manager import list_config_sections, load_config console = Console() diff --git a/cli_tool/commands/config_cmd/core/__init__.py b/cli_tool/commands/config_cmd/core/__init__.py new file mode 100644 index 0000000..b80c7f9 --- /dev/null +++ b/cli_tool/commands/config_cmd/core/__init__.py @@ -0,0 +1,5 @@ +"""Configuration core logic.""" + +from cli_tool.commands.config_cmd.core.descriptions import SECTION_DESCRIPTIONS + +__all__ = ["SECTION_DESCRIPTIONS"] diff --git a/cli_tool/config_cmd/core/descriptions.py b/cli_tool/commands/config_cmd/core/descriptions.py similarity index 100% rename from cli_tool/config_cmd/core/descriptions.py rename to cli_tool/commands/config_cmd/core/descriptions.py diff --git a/cli_tool/dynamodb/README.md b/cli_tool/commands/dynamodb/README.md similarity index 99% rename from cli_tool/dynamodb/README.md rename to cli_tool/commands/dynamodb/README.md index 6b96775..161d810 100644 --- a/cli_tool/dynamodb/README.md +++ b/cli_tool/commands/dynamodb/README.md @@ -5,7 +5,7 @@ DynamoDB utilities for table management and data export. ## Structure ``` -cli_tool/dynamodb/ +cli_tool/commands/dynamodb/ ├── __init__.py # Public API exports ├── README.md # This file ├── commands/ # CLI command definitions diff --git a/cli_tool/dynamodb/__init__.py b/cli_tool/commands/dynamodb/__init__.py similarity index 50% rename from cli_tool/dynamodb/__init__.py rename to cli_tool/commands/dynamodb/__init__.py index 28896c5..cb2113d 100644 --- a/cli_tool/dynamodb/__init__.py +++ b/cli_tool/commands/dynamodb/__init__.py @@ -1,5 +1,5 @@ """DynamoDB export functionality.""" -from cli_tool.dynamodb.commands.cli import dynamodb +from cli_tool.commands.dynamodb.commands.cli import dynamodb __all__ = ["dynamodb"] diff --git a/cli_tool/commands/dynamodb/commands/__init__.py b/cli_tool/commands/dynamodb/commands/__init__.py new file mode 100644 index 0000000..6ed4028 --- /dev/null +++ b/cli_tool/commands/dynamodb/commands/__init__.py @@ -0,0 +1,13 @@ +"""DynamoDB command implementations.""" + +from cli_tool.commands.dynamodb.commands.describe_table import describe_table_command +from cli_tool.commands.dynamodb.commands.export_table import export_table_command +from cli_tool.commands.dynamodb.commands.list_tables import list_tables_command +from cli_tool.commands.dynamodb.commands.list_templates import list_templates_command + +__all__ = [ + "describe_table_command", + "export_table_command", + "list_tables_command", + "list_templates_command", +] diff --git a/cli_tool/dynamodb/commands/cli.py b/cli_tool/commands/dynamodb/commands/cli.py similarity index 96% rename from cli_tool/dynamodb/commands/cli.py rename to cli_tool/commands/dynamodb/commands/cli.py index b837d44..a686e46 100644 --- a/cli_tool/dynamodb/commands/cli.py +++ b/cli_tool/commands/dynamodb/commands/cli.py @@ -4,7 +4,7 @@ import click -from cli_tool.dynamodb.commands import ( +from cli_tool.commands.dynamodb.commands import ( describe_table_command, export_table_command, list_tables_command, @@ -29,7 +29,7 @@ def dynamodb(ctx): @click.pass_context def list_tables(ctx, region: str): """List all DynamoDB tables in the region.""" - from cli_tool.utils.aws import select_profile + from cli_tool.core.utils.aws import select_profile profile = select_profile(ctx.obj.get("profile")) list_tables_command(profile, region) @@ -46,7 +46,7 @@ def list_tables(ctx, region: str): @click.pass_context def describe_table(ctx, table_name: str, region: str): """Show detailed information about a table.""" - from cli_tool.utils.aws import select_profile + from cli_tool.core.utils.aws import select_profile profile = select_profile(ctx.obj.get("profile")) describe_table_command(profile, table_name, region) @@ -226,7 +226,7 @@ def export_table( # Advanced: Manual key condition (rarely needed, auto-detected from --filter) devo dynamodb export my-table --key-condition "userId = :uid" --filter-values '{":uid": "user123"}' """ - from cli_tool.utils.aws import select_profile + from cli_tool.core.utils.aws import select_profile profile = select_profile(ctx.obj.get("profile")) export_table_command( diff --git a/cli_tool/dynamodb/commands/describe_table.py b/cli_tool/commands/dynamodb/commands/describe_table.py similarity index 99% rename from cli_tool/dynamodb/commands/describe_table.py rename to cli_tool/commands/dynamodb/commands/describe_table.py index 969cdbd..f37cfa2 100644 --- a/cli_tool/dynamodb/commands/describe_table.py +++ b/cli_tool/commands/dynamodb/commands/describe_table.py @@ -7,7 +7,7 @@ from rich.panel import Panel from rich.table import Table -from cli_tool.utils.aws import create_aws_client +from cli_tool.core.utils.aws import create_aws_client console = Console() diff --git a/cli_tool/dynamodb/commands/export_table.py b/cli_tool/commands/dynamodb/commands/export_table.py similarity index 99% rename from cli_tool/dynamodb/commands/export_table.py rename to cli_tool/commands/dynamodb/commands/export_table.py index fc630ff..c1e774d 100644 --- a/cli_tool/dynamodb/commands/export_table.py +++ b/cli_tool/commands/dynamodb/commands/export_table.py @@ -10,8 +10,8 @@ from botocore.exceptions import BotoCoreError, ClientError from rich.console import Console -from cli_tool.dynamodb.core import DynamoDBExporter, ParallelScanner, detect_usable_index -from cli_tool.dynamodb.utils import ( +from cli_tool.commands.dynamodb.core import DynamoDBExporter, ParallelScanner, detect_usable_index +from cli_tool.commands.dynamodb.utils import ( ExportConfigManager, FilterBuilder, create_template_from_args, @@ -93,7 +93,7 @@ def export_table_command( try: # Initialize AWS session - from cli_tool.utils.aws import create_aws_client + from cli_tool.core.utils.aws import create_aws_client dynamodb_client = create_aws_client("dynamodb", profile_name=profile, region_name=region) diff --git a/cli_tool/dynamodb/commands/list_tables.py b/cli_tool/commands/dynamodb/commands/list_tables.py similarity index 98% rename from cli_tool/dynamodb/commands/list_tables.py rename to cli_tool/commands/dynamodb/commands/list_tables.py index 40b6ead..1ab2297 100644 --- a/cli_tool/dynamodb/commands/list_tables.py +++ b/cli_tool/commands/dynamodb/commands/list_tables.py @@ -6,7 +6,7 @@ from rich.console import Console from rich.table import Table -from cli_tool.utils.aws import create_aws_client +from cli_tool.core.utils.aws import create_aws_client console = Console() diff --git a/cli_tool/dynamodb/commands/list_templates.py b/cli_tool/commands/dynamodb/commands/list_templates.py similarity index 75% rename from cli_tool/dynamodb/commands/list_templates.py rename to cli_tool/commands/dynamodb/commands/list_templates.py index d4c6f99..bab11a8 100644 --- a/cli_tool/dynamodb/commands/list_templates.py +++ b/cli_tool/commands/dynamodb/commands/list_templates.py @@ -1,6 +1,6 @@ """List export templates command.""" -from cli_tool.dynamodb.utils import ExportConfigManager +from cli_tool.commands.dynamodb.utils import ExportConfigManager def list_templates_command() -> None: diff --git a/cli_tool/dynamodb/core/README.md b/cli_tool/commands/dynamodb/core/README.md similarity index 88% rename from cli_tool/dynamodb/core/README.md rename to cli_tool/commands/dynamodb/core/README.md index 8e03417..10211e5 100644 --- a/cli_tool/dynamodb/core/README.md +++ b/cli_tool/commands/dynamodb/core/README.md @@ -49,9 +49,9 @@ This is much faster than a full table scan with filter. ## Design Philosophy ### Separation of Concerns -- **Commands** (`cli_tool/dynamodb/commands/`) - CLI interface, Click decorators, user interaction -- **Core** (`cli_tool/dynamodb/core/`) - Business logic, no CLI dependencies -- **Utils** (`cli_tool/dynamodb/utils/`) - Helper functions, templates, filters +- **Commands** (`cli_tool/commands/dynamodb/commands/`) - CLI interface, Click decorators, user interaction +- **Core** (`cli_tool/commands/dynamodb/core/`) - Business logic, no CLI dependencies +- **Utils** (`cli_tool/commands/dynamodb/utils/`) - Helper functions, templates, filters ### Auto-Detection Over Manual Configuration Users should rarely need to specify `--key-condition` or `--index` manually. The query optimizer handles this automatically in 90% of cases. diff --git a/cli_tool/commands/dynamodb/core/__init__.py b/cli_tool/commands/dynamodb/core/__init__.py new file mode 100644 index 0000000..5ba0eaf --- /dev/null +++ b/cli_tool/commands/dynamodb/core/__init__.py @@ -0,0 +1,14 @@ +"""DynamoDB core functionality.""" + +from cli_tool.commands.dynamodb.core.exporter import DynamoDBExporter +from cli_tool.commands.dynamodb.core.multi_query_executor import execute_multi_query +from cli_tool.commands.dynamodb.core.parallel_scanner import ParallelScanner +from cli_tool.commands.dynamodb.core.query_optimizer import detect_usable_index, should_use_parallel_scan + +__all__ = [ + "DynamoDBExporter", + "ParallelScanner", + "detect_usable_index", + "execute_multi_query", + "should_use_parallel_scan", +] diff --git a/cli_tool/dynamodb/core/exporter.py b/cli_tool/commands/dynamodb/core/exporter.py similarity index 100% rename from cli_tool/dynamodb/core/exporter.py rename to cli_tool/commands/dynamodb/core/exporter.py diff --git a/cli_tool/dynamodb/core/multi_query_executor.py b/cli_tool/commands/dynamodb/core/multi_query_executor.py similarity index 100% rename from cli_tool/dynamodb/core/multi_query_executor.py rename to cli_tool/commands/dynamodb/core/multi_query_executor.py diff --git a/cli_tool/dynamodb/core/parallel_scanner.py b/cli_tool/commands/dynamodb/core/parallel_scanner.py similarity index 100% rename from cli_tool/dynamodb/core/parallel_scanner.py rename to cli_tool/commands/dynamodb/core/parallel_scanner.py diff --git a/cli_tool/dynamodb/core/query_optimizer.py b/cli_tool/commands/dynamodb/core/query_optimizer.py similarity index 100% rename from cli_tool/dynamodb/core/query_optimizer.py rename to cli_tool/commands/dynamodb/core/query_optimizer.py diff --git a/cli_tool/commands/dynamodb/utils/__init__.py b/cli_tool/commands/dynamodb/utils/__init__.py new file mode 100644 index 0000000..c5c5014 --- /dev/null +++ b/cli_tool/commands/dynamodb/utils/__init__.py @@ -0,0 +1,13 @@ +"""DynamoDB utilities.""" + +from cli_tool.commands.dynamodb.utils.filter_builder import FilterBuilder +from cli_tool.commands.dynamodb.utils.templates import ExportConfigManager, create_template_from_args +from cli_tool.commands.dynamodb.utils.utils import estimate_export_size, validate_table_exists + +__all__ = [ + "ExportConfigManager", + "create_template_from_args", + "FilterBuilder", + "estimate_export_size", + "validate_table_exists", +] diff --git a/cli_tool/dynamodb/utils/filter_builder.py b/cli_tool/commands/dynamodb/utils/filter_builder.py similarity index 100% rename from cli_tool/dynamodb/utils/filter_builder.py rename to cli_tool/commands/dynamodb/utils/filter_builder.py diff --git a/cli_tool/dynamodb/utils/templates.py b/cli_tool/commands/dynamodb/utils/templates.py similarity index 98% rename from cli_tool/dynamodb/utils/templates.py rename to cli_tool/commands/dynamodb/utils/templates.py index ac93cfd..5e33682 100644 --- a/cli_tool/dynamodb/utils/templates.py +++ b/cli_tool/commands/dynamodb/utils/templates.py @@ -5,7 +5,7 @@ from rich.console import Console from rich.table import Table -from cli_tool.utils.config_manager import ( +from cli_tool.core.utils.config_manager import ( delete_dynamodb_template, get_dynamodb_template, get_dynamodb_templates, diff --git a/cli_tool/dynamodb/utils/utils.py b/cli_tool/commands/dynamodb/utils/utils.py similarity index 100% rename from cli_tool/dynamodb/utils/utils.py rename to cli_tool/commands/dynamodb/utils/utils.py diff --git a/cli_tool/eventbridge/__init__.py b/cli_tool/commands/eventbridge/__init__.py similarity index 50% rename from cli_tool/eventbridge/__init__.py rename to cli_tool/commands/eventbridge/__init__.py index bcd82f4..d5bae9f 100644 --- a/cli_tool/eventbridge/__init__.py +++ b/cli_tool/commands/eventbridge/__init__.py @@ -1,5 +1,5 @@ """EventBridge rules management.""" -from cli_tool.eventbridge.commands import register_eventbridge_commands +from cli_tool.commands.eventbridge.commands import register_eventbridge_commands __all__ = ["register_eventbridge_commands"] diff --git a/cli_tool/eventbridge/commands/__init__.py b/cli_tool/commands/eventbridge/commands/__init__.py similarity index 93% rename from cli_tool/eventbridge/commands/__init__.py rename to cli_tool/commands/eventbridge/commands/__init__.py index b1eaa6f..c7199a4 100644 --- a/cli_tool/eventbridge/commands/__init__.py +++ b/cli_tool/commands/eventbridge/commands/__init__.py @@ -2,7 +2,7 @@ import click -from cli_tool.eventbridge.commands.list import list_rules +from cli_tool.commands.eventbridge.commands.list import list_rules def register_eventbridge_commands(): diff --git a/cli_tool/eventbridge/commands/list.py b/cli_tool/commands/eventbridge/commands/list.py similarity index 86% rename from cli_tool/eventbridge/commands/list.py rename to cli_tool/commands/eventbridge/commands/list.py index e787f97..e1b2b5a 100644 --- a/cli_tool/eventbridge/commands/list.py +++ b/cli_tool/commands/eventbridge/commands/list.py @@ -2,9 +2,9 @@ from rich.console import Console -from cli_tool.eventbridge.core.rules_manager import RulesManager -from cli_tool.eventbridge.utils.formatters import format_json_output, format_table_output -from cli_tool.utils.aws import select_profile +from cli_tool.commands.eventbridge.core.rules_manager import RulesManager +from cli_tool.commands.eventbridge.utils.formatters import format_json_output, format_table_output +from cli_tool.core.utils.aws import select_profile console = Console() diff --git a/cli_tool/commands/eventbridge/core/__init__.py b/cli_tool/commands/eventbridge/core/__init__.py new file mode 100644 index 0000000..fca8ecb --- /dev/null +++ b/cli_tool/commands/eventbridge/core/__init__.py @@ -0,0 +1,5 @@ +"""EventBridge core business logic.""" + +from cli_tool.commands.eventbridge.core.rules_manager import RulesManager + +__all__ = ["RulesManager"] diff --git a/cli_tool/eventbridge/core/rules_manager.py b/cli_tool/commands/eventbridge/core/rules_manager.py similarity index 98% rename from cli_tool/eventbridge/core/rules_manager.py rename to cli_tool/commands/eventbridge/core/rules_manager.py index a160cf9..4096883 100644 --- a/cli_tool/eventbridge/core/rules_manager.py +++ b/cli_tool/commands/eventbridge/core/rules_manager.py @@ -4,7 +4,7 @@ from botocore.exceptions import ClientError -from cli_tool.utils.aws import create_aws_client +from cli_tool.core.utils.aws import create_aws_client class RulesManager: diff --git a/cli_tool/commands/eventbridge/utils/__init__.py b/cli_tool/commands/eventbridge/utils/__init__.py new file mode 100644 index 0000000..cdd69c2 --- /dev/null +++ b/cli_tool/commands/eventbridge/utils/__init__.py @@ -0,0 +1,5 @@ +"""EventBridge utilities.""" + +from cli_tool.commands.eventbridge.utils.formatters import format_json_output, format_table_output + +__all__ = ["format_json_output", "format_table_output"] diff --git a/cli_tool/eventbridge/utils/formatters.py b/cli_tool/commands/eventbridge/utils/formatters.py similarity index 100% rename from cli_tool/eventbridge/utils/formatters.py rename to cli_tool/commands/eventbridge/utils/formatters.py diff --git a/cli_tool/ssm/__init__.py b/cli_tool/commands/ssm/__init__.py similarity index 79% rename from cli_tool/ssm/__init__.py rename to cli_tool/commands/ssm/__init__.py index 592704a..b9733e8 100644 --- a/cli_tool/ssm/__init__.py +++ b/cli_tool/commands/ssm/__init__.py @@ -2,7 +2,7 @@ import click -from cli_tool.ssm.commands import ( +from cli_tool.commands.ssm.commands import ( register_database_commands, register_forward_command, register_hosts_commands, @@ -11,8 +11,8 @@ ) # Backward compatibility imports -from cli_tool.ssm.core import PortForwarder, SSMConfigManager, SSMSession -from cli_tool.ssm.utils import HostsManager +from cli_tool.commands.ssm.core import PortForwarder, SSMConfigManager, SSMSession +from cli_tool.commands.ssm.utils import HostsManager # Backward compatibility alias SocatManager = PortForwarder diff --git a/cli_tool/commands/ssm/commands/__init__.py b/cli_tool/commands/ssm/commands/__init__.py new file mode 100644 index 0000000..aae3e89 --- /dev/null +++ b/cli_tool/commands/ssm/commands/__init__.py @@ -0,0 +1,15 @@ +"""SSM commands module.""" + +from cli_tool.commands.ssm.commands.database import register_database_commands +from cli_tool.commands.ssm.commands.forward import register_forward_command +from cli_tool.commands.ssm.commands.hosts import register_hosts_commands +from cli_tool.commands.ssm.commands.instance import register_instance_commands +from cli_tool.commands.ssm.commands.shortcuts import register_shortcuts + +__all__ = [ + "register_database_commands", + "register_instance_commands", + "register_forward_command", + "register_hosts_commands", + "register_shortcuts", +] diff --git a/cli_tool/commands/ssm/commands/database/__init__.py b/cli_tool/commands/ssm/commands/database/__init__.py new file mode 100644 index 0000000..acffbe3 --- /dev/null +++ b/cli_tool/commands/ssm/commands/database/__init__.py @@ -0,0 +1,21 @@ +"""Database connection commands for SSM.""" + +from cli_tool.commands.ssm.commands.database.add import add_database +from cli_tool.commands.ssm.commands.database.connect import connect_database +from cli_tool.commands.ssm.commands.database.list import list_databases +from cli_tool.commands.ssm.commands.database.remove import remove_database + + +def register_database_commands(ssm_group): + """Register database-related commands to the SSM group.""" + + @ssm_group.group("database") + def database(): + """Manage database connections""" + pass + + # Register all database commands + database.add_command(connect_database, "connect") + database.add_command(list_databases, "list") + database.add_command(add_database, "add") + database.add_command(remove_database, "remove") diff --git a/cli_tool/ssm/commands/database/add.py b/cli_tool/commands/ssm/commands/database/add.py similarity index 62% rename from cli_tool/ssm/commands/database/add.py rename to cli_tool/commands/ssm/commands/database/add.py index 1977892..d93f82e 100644 --- a/cli_tool/ssm/commands/database/add.py +++ b/cli_tool/commands/ssm/commands/database/add.py @@ -3,7 +3,7 @@ import click from rich.console import Console -from cli_tool.ssm.core import SSMConfigManager +from cli_tool.commands.ssm.core import SSMConfigManager console = Console() @@ -17,10 +17,10 @@ @click.option("--region", default="us-east-1", help="AWS region") @click.option("--profile", help="AWS profile") def add_database(name, bastion, host, port, local_port, region, profile): - """Add a database configuration""" - config_manager = SSMConfigManager() + """Add a database configuration""" + config_manager = SSMConfigManager() - config_manager.add_database(name=name, bastion=bastion, host=host, port=port, region=region, profile=profile, local_port=local_port) + config_manager.add_database(name=name, bastion=bastion, host=host, port=port, region=region, profile=profile, local_port=local_port) - console.print(f"[green]Database '{name}' added successfully[/green]") - console.print(f"\nConnect with: devo ssm connect {name}") + console.print(f"[green]Database '{name}' added successfully[/green]") + console.print(f"\nConnect with: devo ssm connect {name}") diff --git a/cli_tool/commands/ssm/commands/database/connect.py b/cli_tool/commands/ssm/commands/database/connect.py new file mode 100644 index 0000000..abbc802 --- /dev/null +++ b/cli_tool/commands/ssm/commands/database/connect.py @@ -0,0 +1,229 @@ +"""Database connect command.""" + +import threading +import time + +import click +from rich.console import Console + +from cli_tool.commands.ssm.core import PortForwarder, SSMConfigManager, SSMSession +from cli_tool.commands.ssm.utils import HostsManager + +console = Console() + + +def _connect_all_databases(config_manager, databases, no_hosts): + """Helper function to connect to all databases""" + console.print("[cyan]Starting all connections...[/cyan]\n") + + hosts_manager = HostsManager() + managed_entries = hosts_manager.get_managed_entries() + managed_hosts = {host for _, host in managed_entries} + + port_forwarder = PortForwarder() + threads = [] + + used_local_ports = set() + next_available_port = 15432 + + def get_unique_local_port(preferred_port): + nonlocal next_available_port + if preferred_port not in used_local_ports: + used_local_ports.add(preferred_port) + return preferred_port + while next_available_port in used_local_ports: + next_available_port += 1 + used_local_ports.add(next_available_port) + result = next_available_port + next_available_port += 1 + return result + + def start_connection(name, db_config, actual_local_port): + try: + SSMSession.start_port_forwarding_to_remote( + bastion=db_config["bastion"], + host=db_config["host"], + port=db_config["port"], + local_port=actual_local_port, + region=db_config["region"], + profile=db_config.get("profile"), + ) + except Exception as e: + console.print(f"[red]✗[/red] {name}: {e}") + + for name, db_config in databases.items(): + local_address = db_config.get("local_address", "127.0.0.1") + use_hostname_forwarding = (local_address != "127.0.0.1") and not no_hosts + + if use_hostname_forwarding and db_config["host"] not in managed_hosts: + console.print(f"[yellow]⚠[/yellow] {name}: Not in /etc/hosts (run 'devo ssm hosts setup')") + continue + + if use_hostname_forwarding: + preferred_local_port = db_config.get("local_port", db_config["port"]) + actual_local_port = get_unique_local_port(preferred_local_port) + + profile_text = db_config.get("profile", "default") + port_info = f"{local_address}:{db_config['port']}" + if actual_local_port != preferred_local_port: + port_info += f" [dim](local: {actual_local_port})[/dim]" + + console.print(f"[green]✓[/green] {name}: {db_config['host']} ({port_info}) [dim](profile: {profile_text})[/dim]") + + try: + port_forwarder.start_forward(local_address=local_address, local_port=db_config["port"], target_port=actual_local_port) + thread = threading.Thread(target=start_connection, args=(name, db_config, actual_local_port), daemon=True) + thread.start() + threads.append((name, thread)) + time.sleep(0.5) + except Exception as e: + console.print(f"[red]✗[/red] {name}: {e}") + else: + console.print(f"[yellow]⚠[/yellow] {name}: Hostname forwarding not configured (skipping)") + + if not threads: + console.print("\n[yellow]No databases to connect[/yellow]") + console.print("Run: devo ssm hosts setup") + return + + console.print("\n[green]All connections started![/green]") + console.print("[yellow]Press Ctrl+C to stop all connections[/yellow]\n") + + try: + while any(thread.is_alive() for _, thread in threads): + time.sleep(1) + except KeyboardInterrupt: + console.print("\n[cyan]Stopping all connections...[/cyan]") + port_forwarder.stop_all() + console.print("[green]All connections closed[/green]") + + +@click.command() +@click.argument("name", required=False) +@click.option("--no-hosts", is_flag=True, help="Disable hostname forwarding (use localhost)") +def connect_database(name, no_hosts): + """Connect to a configured database (uses hostname forwarding by default)""" + config_manager = SSMConfigManager() + databases = config_manager.list_databases() + + if not databases: + console.print("[red]No databases configured[/red]") + console.print("\nAdd a database with: devo ssm database add") + return + + if not name: + console.print("[cyan]Select database to connect:[/cyan]\n") + db_list = list(databases.keys()) + + for i, db_name in enumerate(db_list, 1): + db = databases[db_name] + profile_text = db.get("profile", "default") + console.print(f" {i}. {db_name} ({db['host']}) [dim](profile: {profile_text})[/dim]") + + console.print(f" {len(db_list) + 1}. Connect to all databases") + console.print() + + try: + choice = click.prompt("Enter number", type=int, default=1) + + if choice < 1 or choice > len(db_list) + 1: + console.print("[red]Invalid selection[/red]") + return + + if choice == len(db_list) + 1: + _connect_all_databases(config_manager, databases, no_hosts) + return + + name = db_list[choice - 1] + + except (KeyboardInterrupt, click.Abort): + console.print("\n[yellow]Cancelled[/yellow]") + return + + db_config = config_manager.get_database(name) + + if not db_config: + console.print(f"[red]Database '{name}' not found in config[/red]") + console.print("\nAvailable databases:") + for db_name in databases.keys(): + console.print(f" - {db_name}") + return + + local_address = db_config.get("local_address", "127.0.0.1") + use_hostname_forwarding = (local_address != "127.0.0.1") and not no_hosts + + if use_hostname_forwarding: + hosts_manager = HostsManager() + managed_entries = hosts_manager.get_managed_entries() + hostname_in_hosts = any(host == db_config["host"] for _, host in managed_entries) + + if not hostname_in_hosts: + console.print(f"[yellow]Warning: {db_config['host']} not found in /etc/hosts[/yellow]") + console.print("[dim]Run 'devo ssm hosts setup' to configure hostname forwarding[/dim]\n") + + if click.confirm("Continue with localhost forwarding instead?", default=True): + use_hostname_forwarding = False + else: + console.print("[yellow]Cancelled[/yellow]") + return + + if use_hostname_forwarding: + profile_text = db_config.get("profile", "default") + console.print(f"[cyan]Connecting to {name}...[/cyan]") + console.print(f"[dim]Hostname: {db_config['host']}[/dim]") + console.print(f"[dim]Profile: {profile_text}[/dim]") + console.print(f"[dim]Forwarding: {local_address}:{db_config['port']} -> 127.0.0.1:{db_config['local_port']}[/dim]") + console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") + + port_forwarder = PortForwarder() + + try: + port_forwarder.start_forward(local_address=local_address, local_port=db_config["port"], target_port=db_config["local_port"]) + + exit_code = SSMSession.start_port_forwarding_to_remote( + bastion=db_config["bastion"], + host=db_config["host"], + port=db_config["port"], + local_port=db_config["local_port"], + region=db_config["region"], + profile=db_config.get("profile"), + ) + + if exit_code != 0: + console.print("[red]SSM session failed[/red]") + + except KeyboardInterrupt: + console.print("\n[cyan]Stopping...[/cyan]") + except Exception as e: + console.print(f"\n[red]Error: {e}[/red]") + return + finally: + port_forwarder.stop_all() + console.print("[green]Connection closed[/green]") + else: + profile_text = db_config.get("profile", "default") + + if local_address != "127.0.0.1" and no_hosts: + console.print("[yellow]Hostname forwarding disabled (using localhost)[/yellow]") + elif local_address == "127.0.0.1": + console.print("[yellow]Hostname forwarding not configured (using localhost)[/yellow]") + console.print("[dim]Run 'devo ssm hosts setup' to enable hostname forwarding[/dim]\n") + + console.print(f"[cyan]Connecting to {name}...[/cyan]") + console.print(f"[dim]{db_config['host']}:{db_config['port']} -> localhost:{db_config['local_port']}[/dim]") + console.print(f"[dim]Profile: {profile_text}[/dim]") + console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") + + try: + exit_code = SSMSession.start_port_forwarding_to_remote( + bastion=db_config["bastion"], + host=db_config["host"], + port=db_config["port"], + local_port=db_config["local_port"], + region=db_config["region"], + profile=db_config.get("profile"), + ) + if exit_code != 0: + console.print("[red]Connection failed[/red]") + except KeyboardInterrupt: + console.print("\n[green]Connection closed[/green]") diff --git a/cli_tool/commands/ssm/commands/database/list.py b/cli_tool/commands/ssm/commands/database/list.py new file mode 100644 index 0000000..ccf1413 --- /dev/null +++ b/cli_tool/commands/ssm/commands/database/list.py @@ -0,0 +1,32 @@ +"""Database list command.""" + +import click +from rich.console import Console +from rich.table import Table + +from cli_tool.commands.ssm.core import SSMConfigManager + +console = Console() + + +@click.command() +def list_databases(): + """List configured databases""" + config_manager = SSMConfigManager() + databases = config_manager.list_databases() + + if not databases: + console.print("[yellow]No databases configured[/yellow]") + console.print("\nAdd a database with: devo ssm database add") + return + + table = Table(title="Configured Databases") + table.add_column("Name", style="cyan") + table.add_column("Host", style="white") + table.add_column("Port", style="green") + table.add_column("Profile", style="yellow") + + for name, db in databases.items(): + table.add_row(name, db["host"], str(db["port"]), db.get("profile", "-")) + + console.print(table) diff --git a/cli_tool/commands/ssm/commands/database/remove.py b/cli_tool/commands/ssm/commands/database/remove.py new file mode 100644 index 0000000..ef895ec --- /dev/null +++ b/cli_tool/commands/ssm/commands/database/remove.py @@ -0,0 +1,20 @@ +"""Database remove command.""" + +import click +from rich.console import Console + +from cli_tool.commands.ssm.core import SSMConfigManager + +console = Console() + + +@click.command() +@click.argument("name") +def remove_database(name): + """Remove a database configuration""" + config_manager = SSMConfigManager() + + if config_manager.remove_database(name): + console.print(f"[green]Database '{name}' removed[/green]") + else: + console.print(f"[red]Database '{name}' not found[/red]") diff --git a/cli_tool/commands/ssm/commands/forward.py b/cli_tool/commands/ssm/commands/forward.py new file mode 100644 index 0000000..eaf2ac7 --- /dev/null +++ b/cli_tool/commands/ssm/commands/forward.py @@ -0,0 +1,44 @@ +"""Manual port forwarding command for SSM.""" + +import click +from rich.console import Console + +from cli_tool.commands.ssm.core import SSMSession + +console = Console() + + +def register_forward_command(ssm_group): + """Register manual port forwarding command to the SSM group.""" + + @ssm_group.command("forward") + @click.option("--bastion", required=True, help="Bastion instance ID") + @click.option("--host", required=True, help="Database/service endpoint") + @click.option("--port", default=5432, type=int, help="Remote port") + @click.option("--local-port", type=int, help="Local port (default: same as remote)") + @click.option("--region", default="us-east-1", help="AWS region") + @click.option("--profile", help="AWS profile (optional, uses default if not specified)") + def forward_manual(bastion, host, port, local_port, region, profile): + """Manual port forwarding (without using config) + + Note: This command allows --profile for one-off connections. + For saved database configurations, profile is stored in config. + """ + if not local_port: + local_port = port + + console.print(f"[cyan]Forwarding {host}:{port} -> localhost:{local_port}[/cyan]") + console.print(f"[dim]Via bastion: {bastion}[/dim]") + if profile: + console.print(f"[dim]Profile: {profile}[/dim]") + console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") + + try: + SSMSession.start_port_forwarding_to_remote(bastion=bastion, host=host, port=port, local_port=local_port, region=region, profile=profile) + except KeyboardInterrupt: + console.print("\n[green]Connection closed[/green]") + + +def forward_command(): + """Return forward command registration function.""" + return register_forward_command diff --git a/cli_tool/commands/ssm/commands/hosts/__init__.py b/cli_tool/commands/ssm/commands/hosts/__init__.py new file mode 100644 index 0000000..2c2f9de --- /dev/null +++ b/cli_tool/commands/ssm/commands/hosts/__init__.py @@ -0,0 +1,23 @@ +"""/etc/hosts management commands for SSM.""" + +from cli_tool.commands.ssm.commands.hosts.add import hosts_add_single +from cli_tool.commands.ssm.commands.hosts.clear import hosts_clear +from cli_tool.commands.ssm.commands.hosts.list import hosts_list +from cli_tool.commands.ssm.commands.hosts.remove import hosts_remove_single +from cli_tool.commands.ssm.commands.hosts.setup import hosts_setup + + +def register_hosts_commands(ssm_group): + """Register /etc/hosts management commands to the SSM group.""" + + @ssm_group.group("hosts") + def hosts(): + """Manage /etc/hosts entries for hostname forwarding""" + pass + + # Register all hosts commands + hosts.add_command(hosts_setup, "setup") + hosts.add_command(hosts_list, "list") + hosts.add_command(hosts_clear, "clear") + hosts.add_command(hosts_add_single, "add") + hosts.add_command(hosts_remove_single, "remove") diff --git a/cli_tool/commands/ssm/commands/hosts/add.py b/cli_tool/commands/ssm/commands/hosts/add.py new file mode 100644 index 0000000..3f97773 --- /dev/null +++ b/cli_tool/commands/ssm/commands/hosts/add.py @@ -0,0 +1,40 @@ +"""Hosts add command.""" + +import click +from rich.console import Console + +from cli_tool.commands.ssm.core import SSMConfigManager +from cli_tool.commands.ssm.utils import HostsManager + +console = Console() + + +@click.command() +@click.argument("name") +def hosts_add_single(name): + """Add a single database hostname to /etc/hosts""" + config_manager = SSMConfigManager() + hosts_manager = HostsManager() + + db_config = config_manager.get_database(name) + + if not db_config: + console.print(f"[red]Database '{name}' not found[/red]") + return + + # Get or assign loopback IP + if "local_address" not in db_config or db_config["local_address"] == "127.0.0.1": + local_address = hosts_manager.get_next_loopback_ip() + + # Update config + config = config_manager.load() + config["databases"][name]["local_address"] = local_address + config_manager.save(config) + else: + local_address = db_config["local_address"] + + try: + hosts_manager.add_entry(local_address, db_config["host"]) + console.print(f"[green]Added {db_config['host']} -> {local_address}[/green]") + except Exception as e: + console.print(f"[red]Error: {e}[/red]") diff --git a/cli_tool/commands/ssm/commands/hosts/clear.py b/cli_tool/commands/ssm/commands/hosts/clear.py new file mode 100644 index 0000000..d3567cc --- /dev/null +++ b/cli_tool/commands/ssm/commands/hosts/clear.py @@ -0,0 +1,21 @@ +"""Hosts clear command.""" + +import click +from rich.console import Console + +from cli_tool.commands.ssm.utils import HostsManager + +console = Console() + + +@click.command() +@click.confirmation_option(prompt="Remove all Devo CLI entries from /etc/hosts?") +def hosts_clear(): + """Remove all Devo CLI managed entries from /etc/hosts""" + hosts_manager = HostsManager() + + try: + hosts_manager.clear_all() + console.print("[green]All managed entries removed from /etc/hosts[/green]") + except Exception as e: + console.print(f"[red]Error: {e}[/red]") diff --git a/cli_tool/commands/ssm/commands/hosts/list.py b/cli_tool/commands/ssm/commands/hosts/list.py new file mode 100644 index 0000000..4342fac --- /dev/null +++ b/cli_tool/commands/ssm/commands/hosts/list.py @@ -0,0 +1,30 @@ +"""Hosts list command.""" + +import click +from rich.console import Console +from rich.table import Table + +from cli_tool.commands.ssm.utils import HostsManager + +console = Console() + + +@click.command() +def hosts_list(): + """List all /etc/hosts entries managed by Devo CLI""" + hosts_manager = HostsManager() + entries = hosts_manager.get_managed_entries() + + if not entries: + console.print("[yellow]No managed entries in /etc/hosts[/yellow]") + console.print("\nRun: devo ssm hosts setup") + return + + table = Table(title="Managed /etc/hosts Entries") + table.add_column("IP", style="cyan") + table.add_column("Hostname", style="white") + + for ip, hostname in entries: + table.add_row(ip, hostname) + + console.print(table) diff --git a/cli_tool/commands/ssm/commands/hosts/remove.py b/cli_tool/commands/ssm/commands/hosts/remove.py new file mode 100644 index 0000000..34e4446 --- /dev/null +++ b/cli_tool/commands/ssm/commands/hosts/remove.py @@ -0,0 +1,29 @@ +"""Hosts remove command.""" + +import click +from rich.console import Console + +from cli_tool.commands.ssm.core import SSMConfigManager +from cli_tool.commands.ssm.utils import HostsManager + +console = Console() + + +@click.command() +@click.argument("name") +def hosts_remove_single(name): + """Remove a database hostname from /etc/hosts""" + config_manager = SSMConfigManager() + hosts_manager = HostsManager() + + db_config = config_manager.get_database(name) + + if not db_config: + console.print(f"[red]Database '{name}' not found[/red]") + return + + try: + hosts_manager.remove_entry(db_config["host"]) + console.print(f"[green]Removed {db_config['host']} from /etc/hosts[/green]") + except Exception as e: + console.print(f"[red]Error: {e}[/red]") diff --git a/cli_tool/commands/ssm/commands/hosts/setup.py b/cli_tool/commands/ssm/commands/hosts/setup.py new file mode 100644 index 0000000..c58d6dd --- /dev/null +++ b/cli_tool/commands/ssm/commands/hosts/setup.py @@ -0,0 +1,81 @@ +"""Hosts setup command.""" + +import click +from rich.console import Console + +from cli_tool.commands.ssm.core import SSMConfigManager +from cli_tool.commands.ssm.utils import HostsManager + +console = Console() + + +@click.command() +def hosts_setup(): + """Setup /etc/hosts entries for all configured databases""" + config_manager = SSMConfigManager() + hosts_manager = HostsManager() + databases = config_manager.list_databases() + + if not databases: + console.print("[yellow]No databases configured[/yellow]") + return + + console.print("[cyan]Setting up /etc/hosts entries...[/cyan]\n") + + success_count = 0 + error_count = 0 + + # Track used local_port values to detect conflicts + used_local_ports = {} + next_available_port = 15432 # Start from a high port for auto-assignment + + for name, db_config in databases.items(): + # Get or assign loopback IP + if "local_address" not in db_config or db_config["local_address"] == "127.0.0.1": + # Assign new loopback IP + local_address = hosts_manager.get_next_loopback_ip() + + # Update config + config = config_manager.load() + config["databases"][name]["local_address"] = local_address + config_manager.save(config) + else: + local_address = db_config["local_address"] + + # Check for local_port conflicts (multiple DBs trying to use same local port) + local_port = db_config.get("local_port", db_config["port"]) + + if local_port in used_local_ports: + console.print(f"[yellow]⚠[/yellow] {name}: Local port {local_port} already used by {used_local_ports[local_port]}") + console.print(f"[dim] Assigning unique local port {next_available_port}...[/dim]") + + # Assign unique local port + local_port = next_available_port + next_available_port += 1 + + # Update config with new local_port + config = config_manager.load() + config["databases"][name]["local_port"] = local_port + config_manager.save(config) + + used_local_ports[local_port] = name + + # Add to /etc/hosts + try: + hosts_manager.add_entry(local_address, db_config["host"]) + console.print(f"[green]✓[/green] {name}: {db_config['host']} -> {local_address}:{db_config['port']} (local: {local_port})") + success_count += 1 + except Exception as e: + console.print(f"[red]✗[/red] {name}: {e}") + error_count += 1 + + # Show appropriate completion message + if error_count > 0 and success_count == 0: + console.print("\n[red]Setup failed![/red]") + console.print("[yellow]All entries failed. Please run your terminal as Administrator.[/yellow]") + elif error_count > 0: + console.print("\n[yellow]Setup partially complete[/yellow]") + console.print(f"[dim]{success_count} succeeded, {error_count} failed[/dim]") + else: + console.print("\n[green]Setup complete![/green]") + console.print("\n[dim]Your microservices can now use the real hostnames in their configuration.[/dim]") diff --git a/cli_tool/commands/ssm/commands/instance/__init__.py b/cli_tool/commands/ssm/commands/instance/__init__.py new file mode 100644 index 0000000..d4be880 --- /dev/null +++ b/cli_tool/commands/ssm/commands/instance/__init__.py @@ -0,0 +1,21 @@ +"""Instance connection commands for SSM.""" + +from cli_tool.commands.ssm.commands.instance.add import add_instance +from cli_tool.commands.ssm.commands.instance.list import list_instances +from cli_tool.commands.ssm.commands.instance.remove import remove_instance +from cli_tool.commands.ssm.commands.instance.shell import connect_instance + + +def register_instance_commands(ssm_group): + """Register instance-related commands to the SSM group.""" + + @ssm_group.group("instance") + def instance(): + """Manage EC2 instance connections""" + pass + + # Register all instance commands + instance.add_command(connect_instance, "shell") + instance.add_command(list_instances, "list") + instance.add_command(add_instance, "add") + instance.add_command(remove_instance, "remove") diff --git a/cli_tool/ssm/commands/instance/add.py b/cli_tool/commands/ssm/commands/instance/add.py similarity index 53% rename from cli_tool/ssm/commands/instance/add.py rename to cli_tool/commands/ssm/commands/instance/add.py index 1f37c83..06bcaaa 100644 --- a/cli_tool/ssm/commands/instance/add.py +++ b/cli_tool/commands/ssm/commands/instance/add.py @@ -3,7 +3,7 @@ import click from rich.console import Console -from cli_tool.ssm.core import SSMConfigManager +from cli_tool.commands.ssm.core import SSMConfigManager console = Console() @@ -14,10 +14,10 @@ @click.option("--region", default="us-east-1", help="AWS region") @click.option("--profile", help="AWS profile") def add_instance(name, instance_id, region, profile): - """Add an instance configuration""" - config_manager = SSMConfigManager() + """Add an instance configuration""" + config_manager = SSMConfigManager() - config_manager.add_instance(name=name, instance_id=instance_id, region=region, profile=profile) + config_manager.add_instance(name=name, instance_id=instance_id, region=region, profile=profile) - console.print(f"[green]Instance '{name}' added successfully[/green]") - console.print(f"\nConnect with: devo ssm shell {name}") + console.print(f"[green]Instance '{name}' added successfully[/green]") + console.print(f"\nConnect with: devo ssm shell {name}") diff --git a/cli_tool/commands/ssm/commands/instance/list.py b/cli_tool/commands/ssm/commands/instance/list.py new file mode 100644 index 0000000..60a77c0 --- /dev/null +++ b/cli_tool/commands/ssm/commands/instance/list.py @@ -0,0 +1,32 @@ +"""Instance list command.""" + +import click +from rich.console import Console +from rich.table import Table + +from cli_tool.commands.ssm.core import SSMConfigManager + +console = Console() + + +@click.command() +def list_instances(): + """List configured instances""" + config_manager = SSMConfigManager() + instances = config_manager.list_instances() + + if not instances: + console.print("[yellow]No instances configured[/yellow]") + console.print("\nAdd an instance with: devo ssm instance add") + return + + table = Table(title="Configured Instances") + table.add_column("Name", style="cyan") + table.add_column("Instance ID", style="white") + table.add_column("Region", style="green") + table.add_column("Profile", style="yellow") + + for name, inst in instances.items(): + table.add_row(name, inst["instance_id"], inst["region"], inst.get("profile", "-")) + + console.print(table) diff --git a/cli_tool/commands/ssm/commands/instance/remove.py b/cli_tool/commands/ssm/commands/instance/remove.py new file mode 100644 index 0000000..3812e20 --- /dev/null +++ b/cli_tool/commands/ssm/commands/instance/remove.py @@ -0,0 +1,20 @@ +"""Instance remove command.""" + +import click +from rich.console import Console + +from cli_tool.commands.ssm.core import SSMConfigManager + +console = Console() + + +@click.command() +@click.argument("name") +def remove_instance(name): + """Remove an instance configuration""" + config_manager = SSMConfigManager() + + if config_manager.remove_instance(name): + console.print(f"[green]Instance '{name}' removed[/green]") + else: + console.print(f"[red]Instance '{name}' not found[/red]") diff --git a/cli_tool/commands/ssm/commands/instance/shell.py b/cli_tool/commands/ssm/commands/instance/shell.py new file mode 100644 index 0000000..a54aaca --- /dev/null +++ b/cli_tool/commands/ssm/commands/instance/shell.py @@ -0,0 +1,31 @@ +"""Instance shell command.""" + +import click +from rich.console import Console + +from cli_tool.commands.ssm.core import SSMConfigManager, SSMSession + +console = Console() + + +@click.command() +@click.argument("name") +def connect_instance(name): + """Connect to a configured instance via interactive shell""" + config_manager = SSMConfigManager() + instance_config = config_manager.get_instance(name) + + if not instance_config: + console.print(f"[red]Instance '{name}' not found in config[/red]") + console.print("\nAvailable instances:") + for inst_name in config_manager.list_instances().keys(): + console.print(f" - {inst_name}") + return + + console.print(f"[cyan]Connecting to {name} ({instance_config['instance_id']})...[/cyan]") + console.print("[yellow]Type 'exit' to close the session[/yellow]\n") + + try: + SSMSession.start_session(instance_id=instance_config["instance_id"], region=instance_config["region"], profile=instance_config.get("profile")) + except KeyboardInterrupt: + console.print("\n[green]Session closed[/green]") diff --git a/cli_tool/commands/ssm/commands/shortcuts.py b/cli_tool/commands/ssm/commands/shortcuts.py new file mode 100644 index 0000000..aadb93d --- /dev/null +++ b/cli_tool/commands/ssm/commands/shortcuts.py @@ -0,0 +1,42 @@ +"""Shortcuts for most used SSM commands.""" + +import click + + +def register_shortcuts(ssm_group): + """Register shortcut commands for most used operations.""" + + @ssm_group.command("connect", hidden=False) + @click.argument("name", required=False) + @click.option("--no-hosts", is_flag=True, help="Disable hostname forwarding (use localhost)") + @click.pass_context + def connect_shortcut(ctx, name, no_hosts): + """Shortcut for 'devo ssm database connect'""" + # Get the database group and invoke connect + database_group = None + for cmd_name, cmd in ssm_group.commands.items(): + if cmd_name == "database": + database_group = cmd + break + + if database_group: + connect_cmd = database_group.commands.get("connect") + if connect_cmd: + ctx.invoke(connect_cmd, name=name, no_hosts=no_hosts) + + @ssm_group.command("shell", hidden=False) + @click.argument("name") + @click.pass_context + def shell_shortcut(ctx, name): + """Shortcut for 'devo ssm instance shell'""" + # Get the instance group and invoke shell + instance_group = None + for cmd_name, cmd in ssm_group.commands.items(): + if cmd_name == "instance": + instance_group = cmd + break + + if instance_group: + shell_cmd = instance_group.commands.get("shell") + if shell_cmd: + ctx.invoke(shell_cmd, name=name) diff --git a/cli_tool/commands/ssm/core/__init__.py b/cli_tool/commands/ssm/core/__init__.py new file mode 100644 index 0000000..891e36a --- /dev/null +++ b/cli_tool/commands/ssm/core/__init__.py @@ -0,0 +1,7 @@ +"""SSM core business logic.""" + +from cli_tool.commands.ssm.core.config import SSMConfigManager +from cli_tool.commands.ssm.core.port_forwarder import PortForwarder +from cli_tool.commands.ssm.core.session import SSMSession + +__all__ = ["SSMConfigManager", "PortForwarder", "SSMSession"] diff --git a/cli_tool/ssm/core/config.py b/cli_tool/commands/ssm/core/config.py similarity index 98% rename from cli_tool/ssm/core/config.py rename to cli_tool/commands/ssm/core/config.py index 1171f99..bab3e01 100644 --- a/cli_tool/ssm/core/config.py +++ b/cli_tool/commands/ssm/core/config.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Dict, Optional -from cli_tool.utils.config_manager import load_config, save_config +from cli_tool.core.utils.config_manager import load_config, save_config class SSMConfigManager: diff --git a/cli_tool/ssm/core/port_forwarder.py b/cli_tool/commands/ssm/core/port_forwarder.py similarity index 100% rename from cli_tool/ssm/core/port_forwarder.py rename to cli_tool/commands/ssm/core/port_forwarder.py diff --git a/cli_tool/ssm/core/session.py b/cli_tool/commands/ssm/core/session.py similarity index 100% rename from cli_tool/ssm/core/session.py rename to cli_tool/commands/ssm/core/session.py diff --git a/cli_tool/commands/ssm/utils/__init__.py b/cli_tool/commands/ssm/utils/__init__.py new file mode 100644 index 0000000..f79e5de --- /dev/null +++ b/cli_tool/commands/ssm/utils/__init__.py @@ -0,0 +1,5 @@ +"""SSM utilities.""" + +from cli_tool.commands.ssm.utils.hosts_manager import HostsManager + +__all__ = ["HostsManager"] diff --git a/cli_tool/ssm/utils/hosts_manager.py b/cli_tool/commands/ssm/utils/hosts_manager.py similarity index 100% rename from cli_tool/ssm/utils/hosts_manager.py rename to cli_tool/commands/ssm/utils/hosts_manager.py diff --git a/cli_tool/commands/upgrade/__init__.py b/cli_tool/commands/upgrade/__init__.py new file mode 100644 index 0000000..916ddcf --- /dev/null +++ b/cli_tool/commands/upgrade/__init__.py @@ -0,0 +1,5 @@ +"""Upgrade module.""" + +from cli_tool.commands.upgrade.command import upgrade + +__all__ = ["upgrade"] diff --git a/cli_tool/commands/upgrade/command.py b/cli_tool/commands/upgrade/command.py new file mode 100644 index 0000000..2e895cf --- /dev/null +++ b/cli_tool/commands/upgrade/command.py @@ -0,0 +1,5 @@ +"""Upgrade command entry point.""" + +from cli_tool.commands.upgrade.commands.upgrade import upgrade + +__all__ = ["upgrade"] diff --git a/cli_tool/commands/upgrade/commands/__init__.py b/cli_tool/commands/upgrade/commands/__init__.py new file mode 100644 index 0000000..dae338c --- /dev/null +++ b/cli_tool/commands/upgrade/commands/__init__.py @@ -0,0 +1,5 @@ +"""Upgrade commands.""" + +from cli_tool.commands.upgrade.commands.upgrade import upgrade + +__all__ = ["upgrade"] diff --git a/cli_tool/upgrade/commands/upgrade.py b/cli_tool/commands/upgrade/commands/upgrade.py similarity index 93% rename from cli_tool/upgrade/commands/upgrade.py rename to cli_tool/commands/upgrade/commands/upgrade.py index a830900..2653faa 100644 --- a/cli_tool/upgrade/commands/upgrade.py +++ b/cli_tool/commands/upgrade/commands/upgrade.py @@ -7,10 +7,10 @@ import click -from cli_tool.upgrade.core.downloader import download_binary, verify_binary -from cli_tool.upgrade.core.installer import replace_binary -from cli_tool.upgrade.core.platform import detect_platform, get_binary_name, get_executable_path -from cli_tool.upgrade.core.version import get_current_version, get_latest_release +from cli_tool.commands.upgrade.core.downloader import download_binary, verify_binary +from cli_tool.commands.upgrade.core.installer import replace_binary +from cli_tool.commands.upgrade.core.platform import detect_platform, get_binary_name, get_executable_path +from cli_tool.commands.upgrade.core.version import get_current_version, get_latest_release @click.command() @@ -23,7 +23,7 @@ def upgrade(force, check): # Clear cache when checking to force fresh check if check: - from cli_tool.utils.version_check import clear_cache + from cli_tool.core.utils.version_check import clear_cache clear_cache() diff --git a/cli_tool/commands/upgrade/core/__init__.py b/cli_tool/commands/upgrade/core/__init__.py new file mode 100644 index 0000000..66f02b1 --- /dev/null +++ b/cli_tool/commands/upgrade/core/__init__.py @@ -0,0 +1,17 @@ +"""Upgrade core functionality.""" + +from cli_tool.commands.upgrade.core.downloader import download_binary, verify_binary +from cli_tool.commands.upgrade.core.installer import replace_binary +from cli_tool.commands.upgrade.core.platform import detect_platform, get_binary_name, get_executable_path +from cli_tool.commands.upgrade.core.version import get_current_version, get_latest_release + +__all__ = [ + "download_binary", + "verify_binary", + "replace_binary", + "detect_platform", + "get_binary_name", + "get_executable_path", + "get_current_version", + "get_latest_release", +] diff --git a/cli_tool/upgrade/core/downloader.py b/cli_tool/commands/upgrade/core/downloader.py similarity index 100% rename from cli_tool/upgrade/core/downloader.py rename to cli_tool/commands/upgrade/core/downloader.py diff --git a/cli_tool/upgrade/core/installer.py b/cli_tool/commands/upgrade/core/installer.py similarity index 100% rename from cli_tool/upgrade/core/installer.py rename to cli_tool/commands/upgrade/core/installer.py diff --git a/cli_tool/upgrade/core/platform.py b/cli_tool/commands/upgrade/core/platform.py similarity index 100% rename from cli_tool/upgrade/core/platform.py rename to cli_tool/commands/upgrade/core/platform.py diff --git a/cli_tool/upgrade/core/version.py b/cli_tool/commands/upgrade/core/version.py similarity index 100% rename from cli_tool/upgrade/core/version.py rename to cli_tool/commands/upgrade/core/version.py diff --git a/cli_tool/commit/commands/__init__.py b/cli_tool/commit/commands/__init__.py deleted file mode 100644 index 37985e8..0000000 --- a/cli_tool/commit/commands/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Commit CLI commands.""" - -from cli_tool.commit.commands.generate import commit - -__all__ = ["commit"] diff --git a/cli_tool/commit/core/__init__.py b/cli_tool/commit/core/__init__.py deleted file mode 100644 index 9255e6d..0000000 --- a/cli_tool/commit/core/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Commit core business logic.""" - -from cli_tool.commit.core.generator import CommitMessageGenerator - -__all__ = ["CommitMessageGenerator"] diff --git a/cli_tool/config.py b/cli_tool/config.py index daaa048..f61e369 100644 --- a/cli_tool/config.py +++ b/cli_tool/config.py @@ -1,6 +1,6 @@ import os -from cli_tool.utils.config_manager import get_config_value +from cli_tool.core.utils.config_manager import get_config_value BASE_DIR = os.path.dirname(__file__) diff --git a/cli_tool/config_cmd/__init__.py b/cli_tool/config_cmd/__init__.py deleted file mode 100644 index 98ed0d5..0000000 --- a/cli_tool/config_cmd/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Configuration management.""" - -from cli_tool.config_cmd.commands import register_config_commands - -__all__ = ["register_config_commands"] diff --git a/cli_tool/config_cmd/core/__init__.py b/cli_tool/config_cmd/core/__init__.py deleted file mode 100644 index 0a23116..0000000 --- a/cli_tool/config_cmd/core/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Configuration core logic.""" - -from cli_tool.config_cmd.core.descriptions import SECTION_DESCRIPTIONS - -__all__ = ["SECTION_DESCRIPTIONS"] diff --git a/cli_tool/core/__init__.py b/cli_tool/core/__init__.py new file mode 100644 index 0000000..ae0e9ad --- /dev/null +++ b/cli_tool/core/__init__.py @@ -0,0 +1 @@ +"""Core infrastructure package.""" diff --git a/cli_tool/code_reviewer/prompt/__init__.py b/cli_tool/core/agents/__init__.py similarity index 100% rename from cli_tool/code_reviewer/prompt/__init__.py rename to cli_tool/core/agents/__init__.py diff --git a/cli_tool/agents/base_agent.py b/cli_tool/core/agents/base_agent.py similarity index 99% rename from cli_tool/agents/base_agent.py rename to cli_tool/core/agents/base_agent.py index 76a30a2..b1fd6a4 100644 --- a/cli_tool/agents/base_agent.py +++ b/cli_tool/core/agents/base_agent.py @@ -6,7 +6,7 @@ from strands.models import BedrockModel from cli_tool.config import BEDROCK_MODEL_ID, FALLBACK_MODEL_ID -from cli_tool.ui.console_ui import console_ui +from cli_tool.core.ui.console_ui import console_ui T = TypeVar("T", bound=BaseModel) @@ -133,7 +133,7 @@ def _console_ui_callback(self, **kwargs): def _create_agent(self) -> Agent: """Create and configure the Strands agent.""" - from cli_tool.utils.aws import create_aws_session + from cli_tool.core.utils.aws import create_aws_session # Create boto3 session with proper credential handling boto_session = create_aws_session( diff --git a/cli_tool/ui/__init__.py b/cli_tool/core/ui/__init__.py similarity index 100% rename from cli_tool/ui/__init__.py rename to cli_tool/core/ui/__init__.py diff --git a/cli_tool/ui/console_ui.py b/cli_tool/core/ui/console_ui.py similarity index 100% rename from cli_tool/ui/console_ui.py rename to cli_tool/core/ui/console_ui.py diff --git a/cli_tool/utils/__init__.py b/cli_tool/core/utils/__init__.py similarity index 100% rename from cli_tool/utils/__init__.py rename to cli_tool/core/utils/__init__.py diff --git a/cli_tool/utils/aws.py b/cli_tool/core/utils/aws.py similarity index 99% rename from cli_tool/utils/aws.py rename to cli_tool/core/utils/aws.py index c166e11..443b09c 100644 --- a/cli_tool/utils/aws.py +++ b/cli_tool/core/utils/aws.py @@ -20,7 +20,7 @@ def select_profile(current_profile: Optional[str] = None, allow_none: bool = Fal Returns: Selected profile name or None """ - from cli_tool.utils.aws_profile import get_aws_profiles + from cli_tool.core.utils.aws_profile import get_aws_profiles # If profile already set, use it if current_profile: diff --git a/cli_tool/utils/aws_profile.py b/cli_tool/core/utils/aws_profile.py similarity index 99% rename from cli_tool/utils/aws_profile.py rename to cli_tool/core/utils/aws_profile.py index 05cd1a3..5df99f4 100644 --- a/cli_tool/utils/aws_profile.py +++ b/cli_tool/core/utils/aws_profile.py @@ -23,7 +23,7 @@ def get_aws_profiles(): - 'both': Profile in both config and credentials - 'config': Profile in config without SSO """ - from cli_tool.aws_login.core.config import list_aws_profiles + from cli_tool.commands.aws_login.core.config import list_aws_profiles # Get profiles with source information profiles = list_aws_profiles() diff --git a/cli_tool/utils/config_manager.py b/cli_tool/core/utils/config_manager.py similarity index 100% rename from cli_tool/utils/config_manager.py rename to cli_tool/core/utils/config_manager.py diff --git a/cli_tool/utils/git_utils.py b/cli_tool/core/utils/git_utils.py similarity index 100% rename from cli_tool/utils/git_utils.py rename to cli_tool/core/utils/git_utils.py diff --git a/cli_tool/utils/version_check.py b/cli_tool/core/utils/version_check.py similarity index 98% rename from cli_tool/utils/version_check.py rename to cli_tool/core/utils/version_check.py index dac1d13..0920703 100644 --- a/cli_tool/utils/version_check.py +++ b/cli_tool/core/utils/version_check.py @@ -106,7 +106,7 @@ def check_for_updates(): Returns tuple: (has_update, current_version, latest_version) """ # Skip if disabled in config - from cli_tool.utils.config_manager import get_config_value + from cli_tool.core.utils.config_manager import get_config_value if not get_config_value("version_check.enabled", True): return False, None, None diff --git a/cli_tool/dynamodb/commands/__init__.py b/cli_tool/dynamodb/commands/__init__.py deleted file mode 100644 index 4dd711f..0000000 --- a/cli_tool/dynamodb/commands/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""DynamoDB command implementations.""" - -from cli_tool.dynamodb.commands.describe_table import describe_table_command -from cli_tool.dynamodb.commands.export_table import export_table_command -from cli_tool.dynamodb.commands.list_tables import list_tables_command -from cli_tool.dynamodb.commands.list_templates import list_templates_command - -__all__ = [ - "describe_table_command", - "export_table_command", - "list_tables_command", - "list_templates_command", -] diff --git a/cli_tool/dynamodb/core/__init__.py b/cli_tool/dynamodb/core/__init__.py deleted file mode 100644 index acae073..0000000 --- a/cli_tool/dynamodb/core/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""DynamoDB core functionality.""" - -from cli_tool.dynamodb.core.exporter import DynamoDBExporter -from cli_tool.dynamodb.core.multi_query_executor import execute_multi_query -from cli_tool.dynamodb.core.parallel_scanner import ParallelScanner -from cli_tool.dynamodb.core.query_optimizer import detect_usable_index, should_use_parallel_scan - -__all__ = [ - "DynamoDBExporter", - "ParallelScanner", - "detect_usable_index", - "execute_multi_query", - "should_use_parallel_scan", -] diff --git a/cli_tool/dynamodb/utils/__init__.py b/cli_tool/dynamodb/utils/__init__.py deleted file mode 100644 index caa9b2a..0000000 --- a/cli_tool/dynamodb/utils/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""DynamoDB utilities.""" - -from cli_tool.dynamodb.utils.filter_builder import FilterBuilder -from cli_tool.dynamodb.utils.templates import ExportConfigManager, create_template_from_args -from cli_tool.dynamodb.utils.utils import estimate_export_size, validate_table_exists - -__all__ = [ - "ExportConfigManager", - "create_template_from_args", - "FilterBuilder", - "estimate_export_size", - "validate_table_exists", -] diff --git a/cli_tool/eventbridge/core/__init__.py b/cli_tool/eventbridge/core/__init__.py deleted file mode 100644 index de78025..0000000 --- a/cli_tool/eventbridge/core/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""EventBridge core business logic.""" - -from cli_tool.eventbridge.core.rules_manager import RulesManager - -__all__ = ["RulesManager"] diff --git a/cli_tool/eventbridge/utils/__init__.py b/cli_tool/eventbridge/utils/__init__.py deleted file mode 100644 index 1c549dc..0000000 --- a/cli_tool/eventbridge/utils/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""EventBridge utilities.""" - -from cli_tool.eventbridge.utils.formatters import format_json_output, format_table_output - -__all__ = ["format_json_output", "format_table_output"] diff --git a/cli_tool/ssm/commands/__init__.py b/cli_tool/ssm/commands/__init__.py deleted file mode 100644 index 887c688..0000000 --- a/cli_tool/ssm/commands/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""SSM commands module.""" - -from cli_tool.ssm.commands.database import register_database_commands -from cli_tool.ssm.commands.forward import register_forward_command -from cli_tool.ssm.commands.hosts import register_hosts_commands -from cli_tool.ssm.commands.instance import register_instance_commands -from cli_tool.ssm.commands.shortcuts import register_shortcuts - -__all__ = [ - "register_database_commands", - "register_instance_commands", - "register_forward_command", - "register_hosts_commands", - "register_shortcuts", -] diff --git a/cli_tool/ssm/commands/database/__init__.py b/cli_tool/ssm/commands/database/__init__.py deleted file mode 100644 index 075a88f..0000000 --- a/cli_tool/ssm/commands/database/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Database connection commands for SSM.""" - -import click - -from cli_tool.ssm.commands.database.add import add_database -from cli_tool.ssm.commands.database.connect import connect_database -from cli_tool.ssm.commands.database.list import list_databases -from cli_tool.ssm.commands.database.remove import remove_database - - -def register_database_commands(ssm_group): - """Register database-related commands to the SSM group.""" - - @ssm_group.group("database") - def database(): - """Manage database connections""" - pass - - # Register all database commands - database.add_command(connect_database, "connect") - database.add_command(list_databases, "list") - database.add_command(add_database, "add") - database.add_command(remove_database, "remove") diff --git a/cli_tool/ssm/commands/database/connect.py b/cli_tool/ssm/commands/database/connect.py deleted file mode 100644 index 3537776..0000000 --- a/cli_tool/ssm/commands/database/connect.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Database connect command.""" - -import threading -import time - -import click -from rich.console import Console - -from cli_tool.ssm.core import PortForwarder, SSMConfigManager, SSMSession -from cli_tool.ssm.utils import HostsManager - -console = Console() - - -def _connect_all_databases(config_manager, databases, no_hosts): - """Helper function to connect to all databases""" - console.print("[cyan]Starting all connections...[/cyan]\n") - - hosts_manager = HostsManager() - managed_entries = hosts_manager.get_managed_entries() - managed_hosts = {host for _, host in managed_entries} - - port_forwarder = PortForwarder() - threads = [] - - used_local_ports = set() - next_available_port = 15432 - - def get_unique_local_port(preferred_port): - nonlocal next_available_port - if preferred_port not in used_local_ports: - used_local_ports.add(preferred_port) - return preferred_port - while next_available_port in used_local_ports: - next_available_port += 1 - used_local_ports.add(next_available_port) - result = next_available_port - next_available_port += 1 - return result - - def start_connection(name, db_config, actual_local_port): - try: - SSMSession.start_port_forwarding_to_remote( - bastion=db_config["bastion"], - host=db_config["host"], - port=db_config["port"], - local_port=actual_local_port, - region=db_config["region"], - profile=db_config.get("profile"), - ) - except Exception as e: - console.print(f"[red]✗[/red] {name}: {e}") - - for name, db_config in databases.items(): - local_address = db_config.get("local_address", "127.0.0.1") - use_hostname_forwarding = (local_address != "127.0.0.1") and not no_hosts - - if use_hostname_forwarding and db_config["host"] not in managed_hosts: - console.print(f"[yellow]⚠[/yellow] {name}: Not in /etc/hosts (run 'devo ssm hosts setup')") - continue - - if use_hostname_forwarding: - preferred_local_port = db_config.get("local_port", db_config["port"]) - actual_local_port = get_unique_local_port(preferred_local_port) - - profile_text = db_config.get("profile", "default") - port_info = f"{local_address}:{db_config['port']}" - if actual_local_port != preferred_local_port: - port_info += f" [dim](local: {actual_local_port})[/dim]" - - console.print(f"[green]✓[/green] {name}: {db_config['host']} ({port_info}) [dim](profile: {profile_text})[/dim]") - - try: - port_forwarder.start_forward(local_address=local_address, local_port=db_config["port"], target_port=actual_local_port) - thread = threading.Thread(target=start_connection, args=(name, db_config, actual_local_port), daemon=True) - thread.start() - threads.append((name, thread)) - time.sleep(0.5) - except Exception as e: - console.print(f"[red]✗[/red] {name}: {e}") - else: - console.print(f"[yellow]⚠[/yellow] {name}: Hostname forwarding not configured (skipping)") - - if not threads: - console.print("\n[yellow]No databases to connect[/yellow]") - console.print("Run: devo ssm hosts setup") - return - - console.print("\n[green]All connections started![/green]") - console.print("[yellow]Press Ctrl+C to stop all connections[/yellow]\n") - - try: - while any(thread.is_alive() for _, thread in threads): - time.sleep(1) - except KeyboardInterrupt: - console.print("\n[cyan]Stopping all connections...[/cyan]") - port_forwarder.stop_all() - console.print("[green]All connections closed[/green]") - - -@click.command() -@click.argument("name", required=False) -@click.option("--no-hosts", is_flag=True, help="Disable hostname forwarding (use localhost)") -def connect_database(name, no_hosts): - """Connect to a configured database (uses hostname forwarding by default)""" - config_manager = SSMConfigManager() - databases = config_manager.list_databases() - - if not databases: - console.print("[red]No databases configured[/red]") - console.print("\nAdd a database with: devo ssm database add") - return - - if not name: - console.print("[cyan]Select database to connect:[/cyan]\n") - db_list = list(databases.keys()) - - for i, db_name in enumerate(db_list, 1): - db = databases[db_name] - profile_text = db.get("profile", "default") - console.print(f" {i}. {db_name} ({db['host']}) [dim](profile: {profile_text})[/dim]") - - console.print(f" {len(db_list) + 1}. Connect to all databases") - console.print() - - try: - choice = click.prompt("Enter number", type=int, default=1) - - if choice < 1 or choice > len(db_list) + 1: - console.print("[red]Invalid selection[/red]") - return - - if choice == len(db_list) + 1: - _connect_all_databases(config_manager, databases, no_hosts) - return - - name = db_list[choice - 1] - - except (KeyboardInterrupt, click.Abort): - console.print("\n[yellow]Cancelled[/yellow]") - return - - db_config = config_manager.get_database(name) - - if not db_config: - console.print(f"[red]Database '{name}' not found in config[/red]") - console.print("\nAvailable databases:") - for db_name in databases.keys(): - console.print(f" - {db_name}") - return - - local_address = db_config.get("local_address", "127.0.0.1") - use_hostname_forwarding = (local_address != "127.0.0.1") and not no_hosts - - if use_hostname_forwarding: - hosts_manager = HostsManager() - managed_entries = hosts_manager.get_managed_entries() - hostname_in_hosts = any(host == db_config["host"] for _, host in managed_entries) - - if not hostname_in_hosts: - console.print(f"[yellow]Warning: {db_config['host']} not found in /etc/hosts[/yellow]") - console.print("[dim]Run 'devo ssm hosts setup' to configure hostname forwarding[/dim]\n") - - if click.confirm("Continue with localhost forwarding instead?", default=True): - use_hostname_forwarding = False - else: - console.print("[yellow]Cancelled[/yellow]") - return - - if use_hostname_forwarding: - profile_text = db_config.get("profile", "default") - console.print(f"[cyan]Connecting to {name}...[/cyan]") - console.print(f"[dim]Hostname: {db_config['host']}[/dim]") - console.print(f"[dim]Profile: {profile_text}[/dim]") - console.print(f"[dim]Forwarding: {local_address}:{db_config['port']} -> 127.0.0.1:{db_config['local_port']}[/dim]") - console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") - - port_forwarder = PortForwarder() - - try: - port_forwarder.start_forward(local_address=local_address, local_port=db_config["port"], target_port=db_config["local_port"]) - - exit_code = SSMSession.start_port_forwarding_to_remote( - bastion=db_config["bastion"], - host=db_config["host"], - port=db_config["port"], - local_port=db_config["local_port"], - region=db_config["region"], - profile=db_config.get("profile"), - ) - - if exit_code != 0: - console.print("[red]SSM session failed[/red]") - - except KeyboardInterrupt: - console.print("\n[cyan]Stopping...[/cyan]") - except Exception as e: - console.print(f"\n[red]Error: {e}[/red]") - return - finally: - port_forwarder.stop_all() - console.print("[green]Connection closed[/green]") - else: - profile_text = db_config.get("profile", "default") - - if local_address != "127.0.0.1" and no_hosts: - console.print("[yellow]Hostname forwarding disabled (using localhost)[/yellow]") - elif local_address == "127.0.0.1": - console.print("[yellow]Hostname forwarding not configured (using localhost)[/yellow]") - console.print("[dim]Run 'devo ssm hosts setup' to enable hostname forwarding[/dim]\n") - - console.print(f"[cyan]Connecting to {name}...[/cyan]") - console.print(f"[dim]{db_config['host']}:{db_config['port']} -> localhost:{db_config['local_port']}[/dim]") - console.print(f"[dim]Profile: {profile_text}[/dim]") - console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") - - try: - exit_code = SSMSession.start_port_forwarding_to_remote( - bastion=db_config["bastion"], - host=db_config["host"], - port=db_config["port"], - local_port=db_config["local_port"], - region=db_config["region"], - profile=db_config.get("profile"), - ) - if exit_code != 0: - console.print("[red]Connection failed[/red]") - except KeyboardInterrupt: - console.print("\n[green]Connection closed[/green]") diff --git a/cli_tool/ssm/commands/database/list.py b/cli_tool/ssm/commands/database/list.py deleted file mode 100644 index f207bcf..0000000 --- a/cli_tool/ssm/commands/database/list.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Database list command.""" - -import click -from rich.console import Console -from rich.table import Table - -from cli_tool.ssm.core import SSMConfigManager - -console = Console() - - -@click.command() -def list_databases(): - """List configured databases""" - config_manager = SSMConfigManager() - databases = config_manager.list_databases() - - if not databases: - console.print("[yellow]No databases configured[/yellow]") - console.print("\nAdd a database with: devo ssm database add") - return - - table = Table(title="Configured Databases") - table.add_column("Name", style="cyan") - table.add_column("Host", style="white") - table.add_column("Port", style="green") - table.add_column("Profile", style="yellow") - - for name, db in databases.items(): - table.add_row(name, db["host"], str(db["port"]), db.get("profile", "-")) - - console.print(table) diff --git a/cli_tool/ssm/commands/database/remove.py b/cli_tool/ssm/commands/database/remove.py deleted file mode 100644 index 93c2c54..0000000 --- a/cli_tool/ssm/commands/database/remove.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Database remove command.""" - -import click -from rich.console import Console - -from cli_tool.ssm.core import SSMConfigManager - -console = Console() - - -@click.command() -@click.argument("name") -def remove_database(name): - """Remove a database configuration""" - config_manager = SSMConfigManager() - - if config_manager.remove_database(name): - console.print(f"[green]Database '{name}' removed[/green]") - else: - console.print(f"[red]Database '{name}' not found[/red]") diff --git a/cli_tool/ssm/commands/forward.py b/cli_tool/ssm/commands/forward.py deleted file mode 100644 index f06b92e..0000000 --- a/cli_tool/ssm/commands/forward.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Manual port forwarding command for SSM.""" - -import click -from rich.console import Console - -from cli_tool.ssm.core import SSMSession - -console = Console() - - -def register_forward_command(ssm_group): - """Register manual port forwarding command to the SSM group.""" - - @ssm_group.command("forward") - @click.option("--bastion", required=True, help="Bastion instance ID") - @click.option("--host", required=True, help="Database/service endpoint") - @click.option("--port", default=5432, type=int, help="Remote port") - @click.option("--local-port", type=int, help="Local port (default: same as remote)") - @click.option("--region", default="us-east-1", help="AWS region") - @click.option("--profile", help="AWS profile (optional, uses default if not specified)") - def forward_manual(bastion, host, port, local_port, region, profile): - """Manual port forwarding (without using config) - - Note: This command allows --profile for one-off connections. - For saved database configurations, profile is stored in config. - """ - if not local_port: - local_port = port - - console.print(f"[cyan]Forwarding {host}:{port} -> localhost:{local_port}[/cyan]") - console.print(f"[dim]Via bastion: {bastion}[/dim]") - if profile: - console.print(f"[dim]Profile: {profile}[/dim]") - console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") - - try: - SSMSession.start_port_forwarding_to_remote(bastion=bastion, host=host, port=port, local_port=local_port, region=region, profile=profile) - except KeyboardInterrupt: - console.print("\n[green]Connection closed[/green]") - - -def forward_command(): - """Return forward command registration function.""" - return register_forward_command diff --git a/cli_tool/ssm/commands/hosts/__init__.py b/cli_tool/ssm/commands/hosts/__init__.py deleted file mode 100644 index b5f5879..0000000 --- a/cli_tool/ssm/commands/hosts/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""/etc/hosts management commands for SSM.""" - -import click - -from cli_tool.ssm.commands.hosts.add import hosts_add_single -from cli_tool.ssm.commands.hosts.clear import hosts_clear -from cli_tool.ssm.commands.hosts.list import hosts_list -from cli_tool.ssm.commands.hosts.remove import hosts_remove_single -from cli_tool.ssm.commands.hosts.setup import hosts_setup - - -def register_hosts_commands(ssm_group): - """Register /etc/hosts management commands to the SSM group.""" - - @ssm_group.group("hosts") - def hosts(): - """Manage /etc/hosts entries for hostname forwarding""" - pass - - # Register all hosts commands - hosts.add_command(hosts_setup, "setup") - hosts.add_command(hosts_list, "list") - hosts.add_command(hosts_clear, "clear") - hosts.add_command(hosts_add_single, "add") - hosts.add_command(hosts_remove_single, "remove") diff --git a/cli_tool/ssm/commands/hosts/add.py b/cli_tool/ssm/commands/hosts/add.py deleted file mode 100644 index 862cb08..0000000 --- a/cli_tool/ssm/commands/hosts/add.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Hosts add command.""" - -import click -from rich.console import Console - -from cli_tool.ssm.core import SSMConfigManager -from cli_tool.ssm.utils import HostsManager - -console = Console() - - -@click.command() -@click.argument("name") -def hosts_add_single(name): - """Add a single database hostname to /etc/hosts""" - config_manager = SSMConfigManager() - hosts_manager = HostsManager() - - db_config = config_manager.get_database(name) - - if not db_config: - console.print(f"[red]Database '{name}' not found[/red]") - return - - # Get or assign loopback IP - if "local_address" not in db_config or db_config["local_address"] == "127.0.0.1": - local_address = hosts_manager.get_next_loopback_ip() - - # Update config - config = config_manager.load() - config["databases"][name]["local_address"] = local_address - config_manager.save(config) - else: - local_address = db_config["local_address"] - - try: - hosts_manager.add_entry(local_address, db_config["host"]) - console.print(f"[green]Added {db_config['host']} -> {local_address}[/green]") - except Exception as e: - console.print(f"[red]Error: {e}[/red]") diff --git a/cli_tool/ssm/commands/hosts/clear.py b/cli_tool/ssm/commands/hosts/clear.py deleted file mode 100644 index 662307d..0000000 --- a/cli_tool/ssm/commands/hosts/clear.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Hosts clear command.""" - -import click -from rich.console import Console - -from cli_tool.ssm.utils import HostsManager - -console = Console() - - -@click.command() -@click.confirmation_option(prompt="Remove all Devo CLI entries from /etc/hosts?") -def hosts_clear(): - """Remove all Devo CLI managed entries from /etc/hosts""" - hosts_manager = HostsManager() - - try: - hosts_manager.clear_all() - console.print("[green]All managed entries removed from /etc/hosts[/green]") - except Exception as e: - console.print(f"[red]Error: {e}[/red]") diff --git a/cli_tool/ssm/commands/hosts/list.py b/cli_tool/ssm/commands/hosts/list.py deleted file mode 100644 index e914fd2..0000000 --- a/cli_tool/ssm/commands/hosts/list.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Hosts list command.""" - -import click -from rich.console import Console -from rich.table import Table - -from cli_tool.ssm.utils import HostsManager - -console = Console() - - -@click.command() -def hosts_list(): - """List all /etc/hosts entries managed by Devo CLI""" - hosts_manager = HostsManager() - entries = hosts_manager.get_managed_entries() - - if not entries: - console.print("[yellow]No managed entries in /etc/hosts[/yellow]") - console.print("\nRun: devo ssm hosts setup") - return - - table = Table(title="Managed /etc/hosts Entries") - table.add_column("IP", style="cyan") - table.add_column("Hostname", style="white") - - for ip, hostname in entries: - table.add_row(ip, hostname) - - console.print(table) diff --git a/cli_tool/ssm/commands/hosts/remove.py b/cli_tool/ssm/commands/hosts/remove.py deleted file mode 100644 index fb43394..0000000 --- a/cli_tool/ssm/commands/hosts/remove.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Hosts remove command.""" - -import click -from rich.console import Console - -from cli_tool.ssm.core import SSMConfigManager -from cli_tool.ssm.utils import HostsManager - -console = Console() - - -@click.command() -@click.argument("name") -def hosts_remove_single(name): - """Remove a database hostname from /etc/hosts""" - config_manager = SSMConfigManager() - hosts_manager = HostsManager() - - db_config = config_manager.get_database(name) - - if not db_config: - console.print(f"[red]Database '{name}' not found[/red]") - return - - try: - hosts_manager.remove_entry(db_config["host"]) - console.print(f"[green]Removed {db_config['host']} from /etc/hosts[/green]") - except Exception as e: - console.print(f"[red]Error: {e}[/red]") diff --git a/cli_tool/ssm/commands/hosts/setup.py b/cli_tool/ssm/commands/hosts/setup.py deleted file mode 100644 index f7c5992..0000000 --- a/cli_tool/ssm/commands/hosts/setup.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Hosts setup command.""" - -import click -from rich.console import Console - -from cli_tool.ssm.core import SSMConfigManager -from cli_tool.ssm.utils import HostsManager - -console = Console() - - -@click.command() -def hosts_setup(): - """Setup /etc/hosts entries for all configured databases""" - config_manager = SSMConfigManager() - hosts_manager = HostsManager() - databases = config_manager.list_databases() - - if not databases: - console.print("[yellow]No databases configured[/yellow]") - return - - console.print("[cyan]Setting up /etc/hosts entries...[/cyan]\n") - - success_count = 0 - error_count = 0 - - # Track used local_port values to detect conflicts - used_local_ports = {} - next_available_port = 15432 # Start from a high port for auto-assignment - - for name, db_config in databases.items(): - # Get or assign loopback IP - if "local_address" not in db_config or db_config["local_address"] == "127.0.0.1": - # Assign new loopback IP - local_address = hosts_manager.get_next_loopback_ip() - - # Update config - config = config_manager.load() - config["databases"][name]["local_address"] = local_address - config_manager.save(config) - else: - local_address = db_config["local_address"] - - # Check for local_port conflicts (multiple DBs trying to use same local port) - local_port = db_config.get("local_port", db_config["port"]) - - if local_port in used_local_ports: - console.print(f"[yellow]⚠[/yellow] {name}: Local port {local_port} already used by {used_local_ports[local_port]}") - console.print(f"[dim] Assigning unique local port {next_available_port}...[/dim]") - - # Assign unique local port - local_port = next_available_port - next_available_port += 1 - - # Update config with new local_port - config = config_manager.load() - config["databases"][name]["local_port"] = local_port - config_manager.save(config) - - used_local_ports[local_port] = name - - # Add to /etc/hosts - try: - hosts_manager.add_entry(local_address, db_config["host"]) - console.print(f"[green]✓[/green] {name}: {db_config['host']} -> {local_address}:{db_config['port']} (local: {local_port})") - success_count += 1 - except Exception as e: - console.print(f"[red]✗[/red] {name}: {e}") - error_count += 1 - - # Show appropriate completion message - if error_count > 0 and success_count == 0: - console.print("\n[red]Setup failed![/red]") - console.print("[yellow]All entries failed. Please run your terminal as Administrator.[/yellow]") - elif error_count > 0: - console.print("\n[yellow]Setup partially complete[/yellow]") - console.print(f"[dim]{success_count} succeeded, {error_count} failed[/dim]") - else: - console.print("\n[green]Setup complete![/green]") - console.print("\n[dim]Your microservices can now use the real hostnames in their configuration.[/dim]") diff --git a/cli_tool/ssm/commands/instance/__init__.py b/cli_tool/ssm/commands/instance/__init__.py deleted file mode 100644 index 9bd808e..0000000 --- a/cli_tool/ssm/commands/instance/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Instance connection commands for SSM.""" - -import click - -from cli_tool.ssm.commands.instance.add import add_instance -from cli_tool.ssm.commands.instance.list import list_instances -from cli_tool.ssm.commands.instance.remove import remove_instance -from cli_tool.ssm.commands.instance.shell import connect_instance - - -def register_instance_commands(ssm_group): - """Register instance-related commands to the SSM group.""" - - @ssm_group.group("instance") - def instance(): - """Manage EC2 instance connections""" - pass - - # Register all instance commands - instance.add_command(connect_instance, "shell") - instance.add_command(list_instances, "list") - instance.add_command(add_instance, "add") - instance.add_command(remove_instance, "remove") diff --git a/cli_tool/ssm/commands/instance/list.py b/cli_tool/ssm/commands/instance/list.py deleted file mode 100644 index a3ef7eb..0000000 --- a/cli_tool/ssm/commands/instance/list.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Instance list command.""" - -import click -from rich.console import Console -from rich.table import Table - -from cli_tool.ssm.core import SSMConfigManager - -console = Console() - - -@click.command() -def list_instances(): - """List configured instances""" - config_manager = SSMConfigManager() - instances = config_manager.list_instances() - - if not instances: - console.print("[yellow]No instances configured[/yellow]") - console.print("\nAdd an instance with: devo ssm instance add") - return - - table = Table(title="Configured Instances") - table.add_column("Name", style="cyan") - table.add_column("Instance ID", style="white") - table.add_column("Region", style="green") - table.add_column("Profile", style="yellow") - - for name, inst in instances.items(): - table.add_row(name, inst["instance_id"], inst["region"], inst.get("profile", "-")) - - console.print(table) diff --git a/cli_tool/ssm/commands/instance/remove.py b/cli_tool/ssm/commands/instance/remove.py deleted file mode 100644 index e5fadb4..0000000 --- a/cli_tool/ssm/commands/instance/remove.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Instance remove command.""" - -import click -from rich.console import Console - -from cli_tool.ssm.core import SSMConfigManager - -console = Console() - - -@click.command() -@click.argument("name") -def remove_instance(name): - """Remove an instance configuration""" - config_manager = SSMConfigManager() - - if config_manager.remove_instance(name): - console.print(f"[green]Instance '{name}' removed[/green]") - else: - console.print(f"[red]Instance '{name}' not found[/red]") diff --git a/cli_tool/ssm/commands/instance/shell.py b/cli_tool/ssm/commands/instance/shell.py deleted file mode 100644 index 5d1621d..0000000 --- a/cli_tool/ssm/commands/instance/shell.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Instance shell command.""" - -import click -from rich.console import Console - -from cli_tool.ssm.core import SSMConfigManager, SSMSession - -console = Console() - - -@click.command() -@click.argument("name") -def connect_instance(name): - """Connect to a configured instance via interactive shell""" - config_manager = SSMConfigManager() - instance_config = config_manager.get_instance(name) - - if not instance_config: - console.print(f"[red]Instance '{name}' not found in config[/red]") - console.print("\nAvailable instances:") - for inst_name in config_manager.list_instances().keys(): - console.print(f" - {inst_name}") - return - - console.print(f"[cyan]Connecting to {name} ({instance_config['instance_id']})...[/cyan]") - console.print("[yellow]Type 'exit' to close the session[/yellow]\n") - - try: - SSMSession.start_session(instance_id=instance_config["instance_id"], region=instance_config["region"], profile=instance_config.get("profile")) - except KeyboardInterrupt: - console.print("\n[green]Session closed[/green]") diff --git a/cli_tool/ssm/commands/shortcuts.py b/cli_tool/ssm/commands/shortcuts.py deleted file mode 100644 index f951043..0000000 --- a/cli_tool/ssm/commands/shortcuts.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Shortcuts for most used SSM commands.""" - -import click - - -def register_shortcuts(ssm_group): - """Register shortcut commands for most used operations.""" - - @ssm_group.command("connect", hidden=False) - @click.argument("name", required=False) - @click.option("--no-hosts", is_flag=True, help="Disable hostname forwarding (use localhost)") - @click.pass_context - def connect_shortcut(ctx, name, no_hosts): - """Shortcut for 'devo ssm database connect'""" - # Get the database group and invoke connect - database_group = None - for cmd_name, cmd in ssm_group.commands.items(): - if cmd_name == "database": - database_group = cmd - break - - if database_group: - connect_cmd = database_group.commands.get("connect") - if connect_cmd: - ctx.invoke(connect_cmd, name=name, no_hosts=no_hosts) - - @ssm_group.command("shell", hidden=False) - @click.argument("name") - @click.pass_context - def shell_shortcut(ctx, name): - """Shortcut for 'devo ssm instance shell'""" - # Get the instance group and invoke shell - instance_group = None - for cmd_name, cmd in ssm_group.commands.items(): - if cmd_name == "instance": - instance_group = cmd - break - - if instance_group: - shell_cmd = instance_group.commands.get("shell") - if shell_cmd: - ctx.invoke(shell_cmd, name=name) diff --git a/cli_tool/ssm/core/__init__.py b/cli_tool/ssm/core/__init__.py deleted file mode 100644 index fa6b669..0000000 --- a/cli_tool/ssm/core/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""SSM core business logic.""" - -from cli_tool.ssm.core.config import SSMConfigManager -from cli_tool.ssm.core.port_forwarder import PortForwarder -from cli_tool.ssm.core.session import SSMSession - -__all__ = ["SSMConfigManager", "PortForwarder", "SSMSession"] diff --git a/cli_tool/ssm/utils/__init__.py b/cli_tool/ssm/utils/__init__.py deleted file mode 100644 index 3d7c66b..0000000 --- a/cli_tool/ssm/utils/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""SSM utilities.""" - -from cli_tool.ssm.utils.hosts_manager import HostsManager - -__all__ = ["HostsManager"] diff --git a/cli_tool/upgrade/__init__.py b/cli_tool/upgrade/__init__.py deleted file mode 100644 index e61371d..0000000 --- a/cli_tool/upgrade/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Upgrade module.""" - -from cli_tool.upgrade.command import upgrade - -__all__ = ["upgrade"] diff --git a/cli_tool/upgrade/command.py b/cli_tool/upgrade/command.py deleted file mode 100644 index 0014b23..0000000 --- a/cli_tool/upgrade/command.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Upgrade command entry point.""" - -from cli_tool.upgrade.commands.upgrade import upgrade - -__all__ = ["upgrade"] diff --git a/cli_tool/upgrade/commands/__init__.py b/cli_tool/upgrade/commands/__init__.py deleted file mode 100644 index 789cc48..0000000 --- a/cli_tool/upgrade/commands/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Upgrade commands.""" - -from cli_tool.upgrade.commands.upgrade import upgrade - -__all__ = ["upgrade"] diff --git a/cli_tool/upgrade/core/__init__.py b/cli_tool/upgrade/core/__init__.py deleted file mode 100644 index dff6f5c..0000000 --- a/cli_tool/upgrade/core/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Upgrade core functionality.""" - -from cli_tool.upgrade.core.downloader import download_binary, verify_binary -from cli_tool.upgrade.core.installer import replace_binary -from cli_tool.upgrade.core.platform import detect_platform, get_binary_name, get_executable_path -from cli_tool.upgrade.core.version import get_current_version, get_latest_release - -__all__ = [ - "download_binary", - "verify_binary", - "replace_binary", - "detect_platform", - "get_binary_name", - "get_executable_path", - "get_current_version", - "get_latest_release", -] diff --git a/devo.spec b/devo.spec index d51c893..c66253f 100644 --- a/devo.spec +++ b/devo.spec @@ -14,72 +14,96 @@ hidden_imports = [ 'cli_tool', 'cli_tool.cli', 'cli_tool.config', - 'cli_tool.agents', - 'cli_tool.agents.base_agent', - 'cli_tool.autocomplete', - 'cli_tool.autocomplete.commands', - 'cli_tool.autocomplete.core', - 'cli_tool.aws_login', - 'cli_tool.aws_login.commands', - 'cli_tool.aws_login.core', - 'cli_tool.code_reviewer', - 'cli_tool.code_reviewer.commands', - 'cli_tool.code_reviewer.commands.analyze', - 'cli_tool.code_reviewer.core', - 'cli_tool.code_reviewer.core.analyzer', - 'cli_tool.code_reviewer.core.git_utils', - 'cli_tool.code_reviewer.prompt', - 'cli_tool.code_reviewer.prompt.analysis_rules', - 'cli_tool.code_reviewer.prompt.code_reviewer', - 'cli_tool.code_reviewer.prompt.output_format', - 'cli_tool.code_reviewer.prompt.security_standards', - 'cli_tool.code_reviewer.prompt.tools_guide', - 'cli_tool.code_reviewer.tools', - 'cli_tool.code_reviewer.tools.code_analyzer', - 'cli_tool.code_reviewer.tools.file_reader', - 'cli_tool.codeartifact', - 'cli_tool.codeartifact.commands', - 'cli_tool.codeartifact.core', - 'cli_tool.commit', - 'cli_tool.commit.commands', - 'cli_tool.commit.commands.generate', - 'cli_tool.commit.core', - 'cli_tool.commit.core.generator', - 'cli_tool.config_cmd', - 'cli_tool.config_cmd.commands', - 'cli_tool.config_cmd.core', - 'cli_tool.dynamodb', - 'cli_tool.dynamodb.commands', - 'cli_tool.dynamodb.commands.cli', - 'cli_tool.dynamodb.commands.describe_table', - 'cli_tool.dynamodb.commands.export_table', - 'cli_tool.dynamodb.commands.list_tables', - 'cli_tool.dynamodb.commands.list_templates', - 'cli_tool.dynamodb.core', - 'cli_tool.dynamodb.core.exporter', - 'cli_tool.dynamodb.core.parallel_scanner', - 'cli_tool.dynamodb.utils', - 'cli_tool.dynamodb.utils.filter_builder', - 'cli_tool.dynamodb.utils.templates', - 'cli_tool.dynamodb.utils.utils', - 'cli_tool.eventbridge', - 'cli_tool.eventbridge.commands', - 'cli_tool.eventbridge.core', - 'cli_tool.eventbridge.utils', - 'cli_tool.ssm', - 'cli_tool.ssm.commands', - 'cli_tool.ssm.core', - 'cli_tool.ssm.utils', - 'cli_tool.upgrade', - 'cli_tool.upgrade.commands', - 'cli_tool.upgrade.core', - 'cli_tool.ui', - 'cli_tool.ui.console_ui', - 'cli_tool.utils', - 'cli_tool.utils.aws', - 'cli_tool.utils.config_manager', - 'cli_tool.utils.git_utils', - 'cli_tool.utils.version_check', + # Core infrastructure + 'cli_tool.core', + 'cli_tool.core.agents', + 'cli_tool.core.agents.base_agent', + 'cli_tool.core.ui', + 'cli_tool.core.ui.console_ui', + 'cli_tool.core.utils', + 'cli_tool.core.utils.aws', + 'cli_tool.core.utils.aws_profile', + 'cli_tool.core.utils.config_manager', + 'cli_tool.core.utils.git_utils', + 'cli_tool.core.utils.version_check', + # Commands + 'cli_tool.commands', + 'cli_tool.commands.autocomplete', + 'cli_tool.commands.autocomplete.commands', + 'cli_tool.commands.autocomplete.core', + 'cli_tool.commands.aws_login', + 'cli_tool.commands.aws_login.command', + 'cli_tool.commands.aws_login.commands', + 'cli_tool.commands.aws_login.core', + 'cli_tool.commands.aws_login.core.config', + 'cli_tool.commands.aws_login.core.credentials', + 'cli_tool.commands.code_reviewer', + 'cli_tool.commands.code_reviewer.commands', + 'cli_tool.commands.code_reviewer.commands.analyze', + 'cli_tool.commands.code_reviewer.core', + 'cli_tool.commands.code_reviewer.core.analyzer', + 'cli_tool.commands.code_reviewer.core.git_utils', + 'cli_tool.commands.code_reviewer.prompt', + 'cli_tool.commands.code_reviewer.prompt.analysis_rules', + 'cli_tool.commands.code_reviewer.prompt.code_reviewer', + 'cli_tool.commands.code_reviewer.prompt.output_format', + 'cli_tool.commands.code_reviewer.prompt.security_standards', + 'cli_tool.commands.code_reviewer.prompt.tools_guide', + 'cli_tool.commands.code_reviewer.tools', + 'cli_tool.commands.code_reviewer.tools.code_analyzer', + 'cli_tool.commands.code_reviewer.tools.file_reader', + 'cli_tool.commands.codeartifact', + 'cli_tool.commands.codeartifact.commands', + 'cli_tool.commands.codeartifact.core', + 'cli_tool.commands.commit', + 'cli_tool.commands.commit.commands', + 'cli_tool.commands.commit.commands.generate', + 'cli_tool.commands.commit.core', + 'cli_tool.commands.commit.core.generator', + 'cli_tool.commands.config_cmd', + 'cli_tool.commands.config_cmd.commands', + 'cli_tool.commands.config_cmd.core', + 'cli_tool.commands.dynamodb', + 'cli_tool.commands.dynamodb.commands', + 'cli_tool.commands.dynamodb.commands.cli', + 'cli_tool.commands.dynamodb.commands.describe_table', + 'cli_tool.commands.dynamodb.commands.export_table', + 'cli_tool.commands.dynamodb.commands.list_tables', + 'cli_tool.commands.dynamodb.commands.list_templates', + 'cli_tool.commands.dynamodb.core', + 'cli_tool.commands.dynamodb.core.exporter', + 'cli_tool.commands.dynamodb.core.parallel_scanner', + 'cli_tool.commands.dynamodb.core.query_optimizer', + 'cli_tool.commands.dynamodb.core.multi_query_executor', + 'cli_tool.commands.dynamodb.utils', + 'cli_tool.commands.dynamodb.utils.filter_builder', + 'cli_tool.commands.dynamodb.utils.templates', + 'cli_tool.commands.dynamodb.utils.utils', + 'cli_tool.commands.eventbridge', + 'cli_tool.commands.eventbridge.commands', + 'cli_tool.commands.eventbridge.core', + 'cli_tool.commands.eventbridge.utils', + 'cli_tool.commands.ssm', + 'cli_tool.commands.ssm.commands', + 'cli_tool.commands.ssm.commands.database', + 'cli_tool.commands.ssm.commands.forward', + 'cli_tool.commands.ssm.commands.hosts', + 'cli_tool.commands.ssm.commands.instance', + 'cli_tool.commands.ssm.commands.shortcuts', + 'cli_tool.commands.ssm.core', + 'cli_tool.commands.ssm.core.config', + 'cli_tool.commands.ssm.core.port_forwarder', + 'cli_tool.commands.ssm.core.session', + 'cli_tool.commands.ssm.utils', + 'cli_tool.commands.ssm.utils.hosts_manager', + 'cli_tool.commands.upgrade', + 'cli_tool.commands.upgrade.command', + 'cli_tool.commands.upgrade.commands', + 'cli_tool.commands.upgrade.core', + 'cli_tool.commands.upgrade.core.downloader', + 'cli_tool.commands.upgrade.core.installer', + 'cli_tool.commands.upgrade.core.platform', + 'cli_tool.commands.upgrade.core.version', # Third-party dependencies 'click', 'requests', diff --git a/docs/development/contributing.md b/docs/development/contributing.md index e2ddee1..b5b8c78 100755 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -64,8 +64,9 @@ Branch naming: Edit files in: - `cli_tool/commands/` - CLI commands -- `cli_tool/agents/` - AI logic -- `cli_tool/utils/` - Utilities +- `cli_tool/core/agents/` - AI logic +- `cli_tool/core/utils/` - Utilities +- `cli_tool/core/ui/` - UI components - `tests/` - Tests ### 3. Test Changes diff --git a/tests/test_commit_prompt.py b/tests/test_commit_prompt.py index 996e52f..9f5e523 100644 --- a/tests/test_commit_prompt.py +++ b/tests/test_commit_prompt.py @@ -3,7 +3,7 @@ import pytest from click.testing import CliRunner -from cli_tool.commit import commit +from cli_tool.commands.commit import commit @pytest.fixture @@ -11,13 +11,13 @@ def runner(): return CliRunner() -@patch("cli_tool.commit.commands.generate.select_profile") -@patch("cli_tool.commit.commands.generate.get_staged_diff") -@patch("cli_tool.commit.commands.generate.get_branch_name") -@patch("cli_tool.commit.commands.generate.get_remote_url") -@patch("cli_tool.commit.core.generator.BaseAgent") -@patch("cli_tool.commit.commands.generate.subprocess.run") -@patch("cli_tool.commit.commands.generate.webbrowser.open") +@patch("cli_tool.commands.commit.commands.generate.select_profile") +@patch("cli_tool.commands.commit.commands.generate.get_staged_diff") +@patch("cli_tool.commands.commit.commands.generate.get_branch_name") +@patch("cli_tool.commands.commit.commands.generate.get_remote_url") +@patch("cli_tool.commands.commit.core.generator.BaseAgent") +@patch("cli_tool.commands.commit.commands.generate.subprocess.run") +@patch("cli_tool.commands.commit.commands.generate.webbrowser.open") def test_commit_all_options( mock_webbrowser_open, mock_subprocess_run, @@ -75,11 +75,11 @@ def test_commit_all_options( mock_webbrowser_open.assert_called_once() -@patch("cli_tool.commit.commands.generate.select_profile") -@patch("cli_tool.commit.commands.generate.get_staged_diff") -@patch("cli_tool.commit.commands.generate.get_branch_name") -@patch("cli_tool.commit.core.generator.BaseAgent") -@patch("cli_tool.commit.commands.generate.subprocess.run") +@patch("cli_tool.commands.commit.commands.generate.select_profile") +@patch("cli_tool.commands.commit.commands.generate.get_staged_diff") +@patch("cli_tool.commands.commit.commands.generate.get_branch_name") +@patch("cli_tool.commands.commit.core.generator.BaseAgent") +@patch("cli_tool.commands.commit.commands.generate.subprocess.run") def test_commit_manual_message_with_ticket( mock_subprocess_run, mock_base_agent, @@ -119,11 +119,11 @@ def test_commit_manual_message_with_ticket( assert "My manual commit message" in commit_message -@patch("cli_tool.commit.commands.generate.select_profile") -@patch("cli_tool.commit.commands.generate.get_staged_diff") -@patch("cli_tool.commit.commands.generate.get_branch_name") -@patch("cli_tool.commit.core.generator.BaseAgent") -@patch("cli_tool.commit.commands.generate.subprocess.run") +@patch("cli_tool.commands.commit.commands.generate.select_profile") +@patch("cli_tool.commands.commit.commands.generate.get_staged_diff") +@patch("cli_tool.commands.commit.commands.generate.get_branch_name") +@patch("cli_tool.commands.commit.core.generator.BaseAgent") +@patch("cli_tool.commands.commit.commands.generate.subprocess.run") def test_commit_aws_credentials_error( mock_subprocess_run, mock_base_agent, @@ -154,11 +154,11 @@ def test_commit_aws_credentials_error( assert "❌ No AWS credentials found. Please configure your AWS CLI." in result.output -@patch("cli_tool.commit.commands.generate.select_profile") -@patch("cli_tool.commit.commands.generate.get_staged_diff") -@patch("cli_tool.commit.commands.generate.get_branch_name") -@patch("cli_tool.commit.core.generator.BaseAgent") -@patch("cli_tool.commit.commands.generate.subprocess.run") +@patch("cli_tool.commands.commit.commands.generate.select_profile") +@patch("cli_tool.commands.commit.commands.generate.get_staged_diff") +@patch("cli_tool.commands.commit.commands.generate.get_branch_name") +@patch("cli_tool.commands.commit.core.generator.BaseAgent") +@patch("cli_tool.commands.commit.commands.generate.subprocess.run") def test_commit_general_error( mock_subprocess_run, mock_base_agent, @@ -189,11 +189,11 @@ def test_commit_general_error( assert "❌ Error sending request: Some other error" in result.output -@patch("cli_tool.commit.commands.generate.select_profile") -@patch("cli_tool.commit.commands.generate.get_staged_diff") -@patch("cli_tool.commit.commands.generate.get_branch_name") -@patch("cli_tool.commit.core.generator.BaseAgent") -@patch("cli_tool.commit.commands.generate.subprocess.run") +@patch("cli_tool.commands.commit.commands.generate.select_profile") +@patch("cli_tool.commands.commit.commands.generate.get_staged_diff") +@patch("cli_tool.commands.commit.commands.generate.get_branch_name") +@patch("cli_tool.commands.commit.core.generator.BaseAgent") +@patch("cli_tool.commands.commit.commands.generate.subprocess.run") def test_commit_no_ticket_in_branch( mock_subprocess_run, mock_base_agent, @@ -227,10 +227,10 @@ def test_commit_no_ticket_in_branch( assert "TICKET-" not in result.output -@patch("cli_tool.commit.commands.generate.select_profile") +@patch("cli_tool.commands.commit.commands.generate.select_profile") def test_commit_no_staged_changes(mock_select_profile, runner): mock_select_profile.return_value = "default" - with patch("cli_tool.commit.commands.generate.get_staged_diff") as mock_get_staged_diff: + with patch("cli_tool.commands.commit.commands.generate.get_staged_diff") as mock_get_staged_diff: mock_get_staged_diff.return_value = "" result = runner.invoke(commit) @@ -239,11 +239,11 @@ def test_commit_no_staged_changes(mock_select_profile, runner): assert "No staged changes found." in result.output -@patch("cli_tool.commit.commands.generate.select_profile") -@patch("cli_tool.commit.commands.generate.get_staged_diff") -@patch("cli_tool.commit.commands.generate.get_branch_name") -@patch("cli_tool.commit.core.generator.BaseAgent") -@patch("cli_tool.commit.commands.generate.subprocess.run") +@patch("cli_tool.commands.commit.commands.generate.select_profile") +@patch("cli_tool.commands.commit.commands.generate.get_staged_diff") +@patch("cli_tool.commands.commit.commands.generate.get_branch_name") +@patch("cli_tool.commands.commit.core.generator.BaseAgent") +@patch("cli_tool.commands.commit.commands.generate.subprocess.run") def test_commit_structured_output( mock_subprocess_run, mock_base_agent, From 31b466a1613bb44ff28ffadf583e02c938a872cf Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Mon, 2 Mar 2026 01:44:55 -0500 Subject: [PATCH 14/37] docs(commands): update module paths to reflect unified commands directory - Update code-reviewer module path from cli_tool.code_reviewer.commands.analyze to cli_tool.commands.code_reviewer.commands.analyze - Update codeartifact module path from cli_tool.codeartifact to cli_tool.commands.codeartifact.commands.login - Update commit module path from cli_tool.commit.commands.generate to cli_tool.commands.commit.commands.generate - Update upgrade module path from cli_tool.upgrade.commands.upgrade to cli_tool.commands.upgrade.commands.upgrade - Align documentation with recent CLI restructuring that consolidated command modules under unified commands directory --- docs/commands/code-reviewer.md | 2 +- docs/commands/codeartifact.md | 2 +- docs/commands/commit.md | 2 +- docs/commands/upgrade.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/commands/code-reviewer.md b/docs/commands/code-reviewer.md index 7563e0f..14d2957 100644 --- a/docs/commands/code-reviewer.md +++ b/docs/commands/code-reviewer.md @@ -15,7 +15,7 @@ Performs comprehensive analysis of code changes using AWS Bedrock AI. Analyzes s ## Usage ::: mkdocs-click - :module: cli_tool.code_reviewer.commands.analyze + :module: cli_tool.commands.code_reviewer.commands.analyze :command: code_reviewer :prog_name: devo :depth: 1 diff --git a/docs/commands/codeartifact.md b/docs/commands/codeartifact.md index 4300d79..ac3a8b6 100644 --- a/docs/commands/codeartifact.md +++ b/docs/commands/codeartifact.md @@ -15,7 +15,7 @@ Configures pip to authenticate with AWS CodeArtifact repository. Obtains an auth ## Usage ::: mkdocs-click - :module: cli_tool.codeartifact + :module: cli_tool.commands.codeartifact.commands.login :command: codeartifact_login :prog_name: devo :depth: 1 diff --git a/docs/commands/commit.md b/docs/commands/commit.md index 719ef82..5494763 100644 --- a/docs/commands/commit.md +++ b/docs/commands/commit.md @@ -15,7 +15,7 @@ Analyzes staged git changes and generates a properly formatted conventional comm ## Usage ::: mkdocs-click - :module: cli_tool.commit.commands.generate + :module: cli_tool.commands.commit.commands.generate :command: commit :prog_name: devo :depth: 1 diff --git a/docs/commands/upgrade.md b/docs/commands/upgrade.md index 2bddcd7..96b6d3c 100644 --- a/docs/commands/upgrade.md +++ b/docs/commands/upgrade.md @@ -15,7 +15,7 @@ Automatically downloads and installs the latest version of Devo CLI from the con ## Usage ::: mkdocs-click - :module: cli_tool.upgrade.commands.upgrade + :module: cli_tool.commands.upgrade.commands.upgrade :command: upgrade :prog_name: devo :depth: 1 From 73774d5b4cfbc541e28a655822f9cbc6a5132c6b Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Mon, 2 Mar 2026 01:53:36 -0500 Subject: [PATCH 15/37] refactor(core): add explicit exports to agent and utils modules - Export BaseAgent from core.agents module with docstring - Export utility submodules from core.utils with circular dependency guidance - Add code_reviewer command export to code_reviewer module __init__ - Include documentation comments to guide proper import patterns - Maintain consistency with modular architecture established in recent refactors --- cli_tool/commands/code_reviewer/__init__.py | 3 ++- cli_tool/core/agents/__init__.py | 5 +++++ cli_tool/core/utils/__init__.py | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/cli_tool/commands/code_reviewer/__init__.py b/cli_tool/commands/code_reviewer/__init__.py index 5d17aa3..c48f120 100755 --- a/cli_tool/commands/code_reviewer/__init__.py +++ b/cli_tool/commands/code_reviewer/__init__.py @@ -1,6 +1,7 @@ """Code Reviewer - AI-Powered Code Analysis.""" +from cli_tool.commands.code_reviewer.commands.analyze import code_reviewer from cli_tool.commands.code_reviewer.core.analyzer import CodeReviewAnalyzer from cli_tool.commands.code_reviewer.core.git_utils import GitManager -__all__ = ["CodeReviewAnalyzer", "GitManager"] +__all__ = ["code_reviewer", "CodeReviewAnalyzer", "GitManager"] diff --git a/cli_tool/core/agents/__init__.py b/cli_tool/core/agents/__init__.py index e69de29..2344817 100755 --- a/cli_tool/core/agents/__init__.py +++ b/cli_tool/core/agents/__init__.py @@ -0,0 +1,5 @@ +"""AI agent framework.""" + +from cli_tool.core.agents.base_agent import BaseAgent + +__all__ = ["BaseAgent"] diff --git a/cli_tool/core/utils/__init__.py b/cli_tool/core/utils/__init__.py index e69de29..06f9701 100755 --- a/cli_tool/core/utils/__init__.py +++ b/cli_tool/core/utils/__init__.py @@ -0,0 +1,15 @@ +"""Shared utilities.""" + +# Note: Imports are intentionally not done here to avoid circular dependencies +# Import directly from submodules instead: +# from cli_tool.core.utils.aws import create_aws_session, create_aws_client +# from cli_tool.core.utils.config_manager import load_config, save_config +# from cli_tool.core.utils.git_utils import get_branch_name, get_staged_diff + +__all__ = [ + "aws", + "aws_profile", + "config_manager", + "git_utils", + "version_check", +] From 9a205416c6768f6bee1e8e0ddb7df2085f7ef48a Mon Sep 17 00:00:00 2001 From: Eduardo De la Cruz Date: Mon, 2 Mar 2026 02:20:31 -0500 Subject: [PATCH 16/37] docs: add architecture documentation and command READMEs - Add comprehensive architecture.md documenting project structure, design principles, and command patterns - Remove outdated code-organization.md and structure.md steering documents - Update product.md with current product vision and goals - Update QUICKSTART.md and README.md with latest setup and usage information - Add README.md files for codeartifact, commit, config_cmd, eventbridge, ssm, and upgrade commands - Consolidate steering documentation to reflect modular command architecture --- .kiro/steering/architecture.md | 497 +++++++++++++++++++++++ .kiro/steering/code-organization.md | 358 ---------------- .kiro/steering/product.md | 92 +++-- .kiro/steering/structure.md | 71 ---- QUICKSTART.md | 144 ++++++- README.md | 121 +++++- cli_tool/commands/codeartifact/README.md | 101 +++++ cli_tool/commands/commit/README.md | 150 +++++++ cli_tool/commands/config_cmd/README.md | 166 ++++++++ cli_tool/commands/eventbridge/README.md | 98 +++++ cli_tool/commands/ssm/README.md | 227 +++++++++++ cli_tool/commands/upgrade/README.md | 134 ++++++ 12 files changed, 1677 insertions(+), 482 deletions(-) create mode 100644 .kiro/steering/architecture.md delete mode 100644 .kiro/steering/code-organization.md delete mode 100644 .kiro/steering/structure.md create mode 100644 cli_tool/commands/codeartifact/README.md create mode 100644 cli_tool/commands/commit/README.md create mode 100644 cli_tool/commands/config_cmd/README.md create mode 100644 cli_tool/commands/eventbridge/README.md create mode 100644 cli_tool/commands/ssm/README.md create mode 100644 cli_tool/commands/upgrade/README.md diff --git a/.kiro/steering/architecture.md b/.kiro/steering/architecture.md new file mode 100644 index 0000000..228bf3c --- /dev/null +++ b/.kiro/steering/architecture.md @@ -0,0 +1,497 @@ +# Architecture Overview + +Devo CLI follows a modular, scalable architecture with clear separation of concerns. + +## Project Structure + +``` +devo-cli/ +├── cli_tool/ # Main package +│ ├── cli.py # CLI entry point +│ ├── config.py # Global configuration +│ ├── _version.py # Auto-generated version +│ │ +│ ├── commands/ # Feature commands +│ │ ├── aws_login/ # AWS SSO authentication +│ │ ├── autocomplete/ # Shell completion +│ │ ├── code_reviewer/ # AI code review +│ │ ├── codeartifact/ # CodeArtifact auth +│ │ ├── commit/ # AI commit messages +│ │ ├── config_cmd/ # Configuration management +│ │ ├── dynamodb/ # DynamoDB utilities +│ │ ├── eventbridge/ # EventBridge management +│ │ ├── ssm/ # SSM Session Manager +│ │ └── upgrade/ # Self-update system +│ │ +│ └── core/ # Shared infrastructure +│ ├── agents/ # AI agent framework +│ ├── ui/ # Rich UI components +│ └── utils/ # Shared utilities +│ +├── tests/ # Test suite +├── docs/ # Documentation +└── .kiro/ # Kiro IDE configuration +``` + +## Design Principles + +### 1. Modularity + +Each command is a self-contained module with its own structure: + +``` +command_name/ +├── __init__.py # Public API exports +├── README.md # Command documentation +├── commands/ # CLI definitions +│ ├── __init__.py +│ └── *.py # Individual commands +├── core/ # Business logic +│ ├── __init__.py +│ └── *.py # Core classes +└── utils/ # Command-specific utilities + ├── __init__.py + └── *.py # Helper functions +``` + +### 2. Separation of Concerns + +**Commands Layer** (`commands/`) +- Click decorators and CLI interface +- User input validation +- Output formatting with Rich +- Error handling and user messages +- NO business logic + +**Core Layer** (`core/`) +- Business logic +- Data processing +- API calls +- NO Click dependencies +- NO Rich console output (return data) + +**Utils Layer** (`utils/`) +- Helper functions +- Data transformations +- Validators +- Reusable across commands + +### 3. Shared Infrastructure + +**Core Infrastructure** (`cli_tool/core/`) +- `agents/` - AI agent framework (BaseAgent) +- `ui/` - Rich UI components (console_ui) +- `utils/` - Shared utilities (config_manager, aws, git_utils) + +## Key Components + +### CLI Entry Point + +`cli_tool/cli.py` - Main Click group that: +- Registers all commands +- Handles global options (--profile, --version) +- Manages context passing +- Shows version check notifications + +### Configuration System + +`cli_tool/core/utils/config_manager.py` - Centralized config: +- JSON-based configuration +- Nested key access with dot notation +- Default values +- Validation + +Location: `~/.devo/config.json` + +### AI Agent Framework + +`cli_tool/core/agents/base_agent.py` - BaseAgent class: +- AWS Bedrock integration +- Pydantic models for structured outputs +- System prompt management +- Tool integration + +Used by: +- `commit` - Commit message generation +- `code-reviewer` - Code analysis + +### Version Management + +`cli_tool/_version.py` - Auto-generated from git tags: +- Uses setuptools_scm +- Semantic versioning +- Fallback to "0.0.0" for development + +### Binary Distribution + +PyInstaller-based binaries: +- **Linux**: Single executable +- **macOS**: Tarball with onedir structure +- **Windows**: ZIP with onedir structure + +## Command Architecture Patterns + +### Simple Command + +Single command with minimal logic: + +``` +command_name/ +├── __init__.py # Public API exports (includes CLI command) +├── README.md # Feature documentation (optional) +├── commands/ +│ └── main.py # Single command implementation +└── core/ + └── processor.py # Business logic +``` + +Example: `autocomplete`, `upgrade` + +**Key Principles:** +- One file per command (~50-100 lines each) +- CLI command exported from `__init__.py` for direct import in `cli.py` +- All feature code contained within feature directory + +### Command Group + +Multiple related commands: + +``` +command_name/ +├── __init__.py # Public API exports +├── README.md # Feature documentation (optional) +├── commands/ +│ ├── __init__.py # Registers all commands +│ ├── list.py # List resources (~30-50 lines) +│ ├── add.py # Create resource (~30-50 lines) +│ └── remove.py # Delete resource (~20-30 lines) +└── core/ + └── manager.py # Main service class +``` + +Example: `config`, `eventbridge` + +### Complex Feature + +Multiple command groups with subcommands: + +``` +command_name/ +├── __init__.py # Public API exports +├── README.md # Feature documentation (optional) +├── commands/ # CLI command definitions +│ ├── __init__.py # Registers all command groups +│ ├── resource1/ # Command group for resource1 +│ │ ├── __init__.py # Registers resource1 commands +│ │ ├── list.py # List resources (~30-50 lines) +│ │ ├── add.py # Create resource (~30-50 lines) +│ │ └── remove.py # Delete resource (~20-30 lines) +│ ├── resource2/ # Command group for resource2 +│ │ ├── __init__.py +│ │ ├── command1.py +│ │ └── command2.py +│ ├── standalone.py # Standalone command (no group) +│ └── shortcuts.py # Shortcuts for common commands (optional) +├── core/ # Business logic (no Click dependencies) +│ ├── __init__.py +│ ├── manager.py # Main service class +│ └── processor.py # Data processing +└── utils/ # Feature-specific utilities (optional) + ├── __init__.py + └── helpers.py # Helper functions +``` + +Example: `ssm`, `dynamodb` + +**Reference Implementation - SSM:** +``` +cli_tool/commands/ssm/ +├── __init__.py +├── commands/ +│ ├── database/ # Database command group +│ │ ├── connect.py # ~230 lines (complex logic) +│ │ ├── list.py # ~30 lines +│ │ ├── add.py # ~25 lines +│ │ └── remove.py # ~20 lines +│ ├── instance/ # Instance command group +│ │ ├── shell.py # ~30 lines +│ │ ├── list.py # ~30 lines +│ │ ├── add.py # ~25 lines +│ │ └── remove.py # ~20 lines +│ ├── hosts/ # Hosts command group +│ │ ├── setup.py # ~80 lines +│ │ ├── list.py # ~25 lines +│ │ ├── clear.py # ~20 lines +│ │ ├── add.py # ~35 lines +│ │ └── remove.py # ~25 lines +│ ├── forward.py # Standalone command (~40 lines) +│ └── shortcuts.py # Shortcuts (~40 lines) +├── core/ +│ ├── config.py # SSMConfigManager +│ ├── session.py # SSMSession +│ └── port_forwarder.py # PortForwarder +└── utils/ + └── hosts_manager.py # HostsManager +``` + +### AI-Powered Feature + +Commands with AI integration: + +``` +command_name/ +├── __init__.py +├── commands/ +│ └── analyze.py +├── core/ +│ └── analyzer.py # Extends BaseAgent +├── prompt/ +│ ├── system_prompt.py +│ └── rules.py +└── tools/ + └── analysis_tools.py +``` + +Example: `code-reviewer` + +## Data Flow + +### User Command Execution + +``` +User Input + ↓ +CLI Entry Point (cli.py) + ↓ +Command Handler (commands/*.py) + ↓ +Core Business Logic (core/*.py) + ↓ +External Services (AWS, Git, etc.) + ↓ +Response Processing + ↓ +Rich UI Output + ↓ +User +``` + +### Configuration Access + +``` +Command + ↓ +config_manager.load_config() + ↓ +~/.devo/config.json + ↓ +Return config dict + ↓ +Command uses config +``` + +### AI Agent Flow + +``` +Command + ↓ +Create Agent Instance (extends BaseAgent) + ↓ +Set System Prompt + ↓ +Call AWS Bedrock + ↓ +Parse Structured Output (Pydantic) + ↓ +Return Result + ↓ +Command formats output +``` + +## Code Organization Rules + +### File Naming Conventions + +- **Commands:** `snake_case.py` (e.g., `aws_login.py`, `commit_prompt.py`) +- **Modules:** `snake_case.py` (e.g., `config_manager.py`, `git_utils.py`) +- **Classes:** `PascalCase` (e.g., `SSMConfigManager`, `BaseAgent`) +- **Functions:** `snake_case` (e.g., `load_config()`, `get_template()`) +- **Constants:** `UPPER_SNAKE_CASE` (e.g., `BEDROCK_MODEL_ID`) +- **Private members:** prefix `_` (e.g., `_internal_method`) + +### Configuration Management + +All configuration must use the centralized config manager: + +```python +from cli_tool.core.utils.config_manager import load_config, save_config + +# Read config +config = load_config() +feature_config = config.get("feature_name", {}) + +# Write config +config["feature_name"] = new_config +save_config(config) +``` + +### Command File Structure Example + +```python +"""Command description.""" + +import click +from rich.console import Console + +from cli_tool.feature_name.core import FeatureManager + +console = Console() + + +@click.command() +@click.argument("name") +@click.option("--flag", is_flag=True, help="Flag description") +def command_name(name, flag): + """Command description.""" + manager = FeatureManager() + result = manager.do_something(name, flag) + console.print(f"[green]✓ Success: {result}[/green]") +``` + +### Command Group Registration Example + +```python +"""Resource commands.""" + +import click + +from cli_tool.feature_name.commands.resource.add import add_resource +from cli_tool.feature_name.commands.resource.list import list_resources +from cli_tool.feature_name.commands.resource.remove import remove_resource + + +def register_resource_commands(parent_group): + """Register resource-related commands.""" + + @parent_group.group("resource") + def resource(): + """Manage resources""" + pass + + # Register all resource commands + resource.add_command(list_resources, "list") + resource.add_command(add_resource, "add") + resource.add_command(remove_resource, "remove") +``` + +### Testing Structure + +Tests should mirror the source structure: + +``` +tests/ +├── test_feature_name/ +│ ├── test_commands.py +│ ├── test_core.py +│ └── test_utils.py +└── test_simple_command.py +``` + +### Documentation Requirements + +Each feature module should have: + +1. **README.md** - Feature overview, usage examples +2. **Docstrings** - All public functions and classes +3. **Type hints** - All function signatures + +## Benefits of This Architecture + +1. **Consistency** - Easy to find code across features +2. **Maintainability** - Clear separation of concerns +3. **Testability** - Business logic isolated from CLI +4. **Scalability** - Easy to add new subcommands (just add new file) +5. **Onboarding** - New developers know where to look +6. **Small Files** - Each command file is 20-100 lines (easy to understand) +7. **Git Friendly** - Less merge conflicts with small, focused files +8. **Discoverability** - File structure mirrors CLI structure + +## Build & Distribution + +### Development +```bash +pip install -e . +``` + +### Binary Build +```bash +pyinstaller devo.spec +``` + +### Release Process +1. Commit with conventional format +2. Push to main branch +3. Semantic Release analyzes commits +4. Creates git tag +5. Builds binaries for all platforms +6. Creates GitHub release +7. Uploads binaries as assets + +## Security Considerations + +### Credentials +- Never store credentials in code +- Use AWS credential chain +- Support AWS profiles +- Respect environment variables + +### Configuration +- Store config in user home directory +- Use JSON for human readability +- Validate all inputs +- Sanitize outputs + +### Binary Distribution +- Sign binaries (future) +- Verify downloads with checksums +- Use HTTPS for all downloads +- Automatic security updates + +## Performance Optimizations + +### Binary Startup +- Onedir format for macOS/Windows (faster) +- Exclude unnecessary modules +- Lazy imports where possible + +### AWS Operations +- Reuse boto3 sessions +- Cache credentials +- Parallel operations where safe + +### DynamoDB Exports +- Parallel scanning +- Streaming writes +- Compression support + +## Future Enhancements + +### Planned Features +- Plugin system for custom commands +- Local caching for faster operations +- Offline mode for some commands +- Enhanced error recovery + +### Architecture Improvements +- Command dependency injection +- Event-driven architecture +- Async operations +- Better error handling + +## Contributing + +See [Contributing Guide](development/contributing.md) for: +- Code style guidelines +- Testing requirements +- Pull request process +- Release procedures diff --git a/.kiro/steering/code-organization.md b/.kiro/steering/code-organization.md deleted file mode 100644 index 7c2152a..0000000 --- a/.kiro/steering/code-organization.md +++ /dev/null @@ -1,358 +0,0 @@ -# Code Organization Standard - -## Project Structure Overview - -The Devo CLI project follows a clean, scalable architecture that separates commands from core infrastructure: - -``` -cli_tool/ -├── __init__.py -├── _version.py # Auto-generated from git tags -├── cli.py # CLI entry point -├── config.py # Global configuration -│ -├── commands/ # All feature commands -│ ├── __init__.py -│ ├── aws_login/ -│ ├── autocomplete/ -│ ├── code_reviewer/ -│ ├── codeartifact/ -│ ├── commit/ -│ ├── config_cmd/ -│ ├── dynamodb/ -│ ├── eventbridge/ -│ ├── ssm/ -│ └── upgrade/ -│ -└── core/ # Core infrastructure (shared) - ├── __init__.py - ├── agents/ # AI agent framework - ├── ui/ # Rich UI components - └── utils/ # Shared utilities -``` - -## Key Benefits - -1. **Clear Separation**: Commands vs Core infrastructure -2. **Scalability**: Easy to add new commands without cluttering root -3. **Navigation**: All features in one place (`commands/`) -4. **Import Clarity**: - - Commands: `from cli_tool.commands.ssm import ssm` - - Core: `from cli_tool.core.agents import BaseAgent` - -## Command Structure - -All commands in Devo CLI must follow this standardized structure for consistency and maintainability. - -## Directory Structure - -### Universal Feature Module Structure - -**ALL commands MUST follow this structure, regardless of size or complexity:** - -``` -cli_tool/commands/feature_name/ -├── __init__.py # Public API exports (includes CLI command) -├── README.md # Feature documentation (optional) -├── commands/ # CLI command definitions -│ ├── __init__.py # Registers all command groups -│ ├── resource1/ # Command group for resource1 -│ │ ├── __init__.py # Registers resource1 commands -│ │ ├── list.py # List resources (~30-50 lines) -│ │ ├── add.py # Create resource (~30-50 lines) -│ │ ├── remove.py # Delete resource (~20-30 lines) -│ │ └── update.py # Update resource (~30-50 lines) -│ ├── resource2/ # Command group for resource2 -│ │ ├── __init__.py -│ │ ├── command1.py -│ │ └── command2.py -│ ├── standalone.py # Standalone command (no group) -│ └── shortcuts.py # Shortcuts for common commands (optional) -├── core/ # Business logic (no Click dependencies) -│ ├── __init__.py -│ ├── manager.py # Main service class -│ └── processor.py # Data processing -└── utils/ # Feature-specific utilities (optional) - ├── __init__.py - └── helpers.py # Helper functions -``` - -**Key Principles:** -- One file per command (~50-100 lines each) -- Commands grouped in subdirectories by domain -- Shortcuts/aliases in separate file -- All feature code contained within feature directory -- CLI command exported from `__init__.py` for direct import in `cli.py` - -**Examples:** `commands/ssm/`, `commands/dynamodb/`, `commands/code_reviewer/` - -**No exceptions:** Even single-command features use this structure for consistency. - -## File Naming Conventions - -- **Commands:** `snake_case.py` (e.g., `aws_login.py`, `commit_prompt.py`) -- **Modules:** `snake_case.py` (e.g., `config_manager.py`, `git_utils.py`) -- **Classes:** `PascalCase` (e.g., `SSMConfigManager`, `BaseAgent`) -- **Functions:** `snake_case` (e.g., `load_config()`, `get_template()`) - -## Code Examples - -### Command File Structure (Individual Command) - -```python -"""Command description.""" - -import click -from rich.console import Console - -from cli_tool.feature_name.core import FeatureManager - -console = Console() - - -@click.command() -@click.argument("name") -@click.option("--flag", is_flag=True, help="Flag description") -def command_name(name, flag): - """Command description.""" - manager = FeatureManager() - result = manager.do_something(name, flag) - console.print(f"[green]✓ Success: {result}[/green]") -``` - -### Command Group Registration (__init__.py) - -```python -"""Resource commands.""" - -import click - -from cli_tool.feature_name.commands.resource.add import add_resource -from cli_tool.feature_name.commands.resource.list import list_resources -from cli_tool.feature_name.commands.resource.remove import remove_resource - - -def register_resource_commands(parent_group): - """Register resource-related commands.""" - - @parent_group.group("resource") - def resource(): - """Manage resources""" - pass - - # Register all resource commands - resource.add_command(list_resources, "list") - resource.add_command(add_resource, "add") - resource.add_command(remove_resource, "remove") -``` - -## Configuration Management - -### Centralized Config - -All configuration must use the centralized config manager: - -```python -from cli_tool.core.utils.config_manager import load_config, save_config - -# Read config -config = load_config() -feature_config = config.get("feature_name", {}) - -# Write config -config["feature_name"] = new_config -save_config(config) -``` - -### Feature-Specific Config Helpers - -Create helper functions in `cli_tool/core/utils/config_manager.py`: - -```python -def get_feature_config() -> Dict: - """Get feature configuration.""" - config = load_config() - return config.get("feature_name", {}) - -def save_feature_config(feature_config: Dict): - """Save feature configuration.""" - config = load_config() - config["feature_name"] = feature_config - save_config(config) -``` - -## Separation of Concerns - -### Commands Layer (`cli_tool/commands/feature/commands/`) -- Click decorators and CLI interface -- User input validation -- Output formatting with Rich -- Error handling and user messages -- **NO business logic** - -### Core Layer (`cli_tool/commands/feature/core/`) -- Business logic -- Data processing -- API calls -- **NO Click dependencies** -- **NO Rich console output** (return data, let commands format) - -### Utils Layer (`cli_tool/commands/feature/utils/`) -- Helper functions -- Data transformations -- Validators -- **Reusable across commands** - -### Shared Core (`cli_tool/core/`) -- `agents/` - AI agent framework (BaseAgent) -- `ui/` - Rich UI components (console_ui) -- `utils/` - Shared utilities (config_manager, aws, git_utils) - -## Current State - -### ✅ All Features Migrated -- `cli_tool/commands/ssm/` - Reference implementation with commands/, core/, utils/ -- `cli_tool/commands/dynamodb/` - Well organized with commands/, core/, utils/ -- `cli_tool/commands/code_reviewer/` - Good separation with commands/, core/, prompt/, tools/ -- `cli_tool/commands/eventbridge/` - Organized with commands/, core/, utils/ -- `cli_tool/commands/config_cmd/` - Organized with commands/, core/ -- `cli_tool/commands/aws_login/` - Reorganized with commands/, core/ -- `cli_tool/commands/upgrade/` - Reorganized with commands/, core/ -- `cli_tool/commands/autocomplete/` - Reorganized with commands/, core/ -- `cli_tool/commands/codeartifact/` - Reorganized with commands/, core/ -- `cli_tool/commands/commit/` - Reorganized with commands/, core/ - -### ✅ Core Infrastructure -- `cli_tool/core/agents/` - AI agent framework -- `cli_tool/core/ui/` - Rich UI components -- `cli_tool/core/utils/` - Shared utilities - -## Migration Completed - -### Phase 1: Standardize Features ✅ COMPLETED -All features migrated to standardized structure within their directories. - -### Phase 2: Top-Level Reorganization ✅ COMPLETED -- Created `cli_tool/commands/` - All feature commands -- Created `cli_tool/core/` - Shared infrastructure -- Moved all features to `commands/` -- Moved agents, ui, utils to `core/` -- Updated all imports across the codebase - -### Phase 3: New Features -All new features must follow the standard structure from day one. - -## Examples - -### ✅ Good: SSM Structure (Reference Implementation) -``` -cli_tool/commands/ssm/ -├── __init__.py -├── commands/ -│ ├── __init__.py -│ ├── database/ # Database command group -│ │ ├── __init__.py -│ │ ├── connect.py # ~230 lines (complex logic) -│ │ ├── list.py # ~30 lines -│ │ ├── add.py # ~25 lines -│ │ └── remove.py # ~20 lines -│ ├── instance/ # Instance command group -│ │ ├── __init__.py -│ │ ├── shell.py # ~30 lines -│ │ ├── list.py # ~30 lines -│ │ ├── add.py # ~25 lines -│ │ └── remove.py # ~20 lines -│ ├── hosts/ # Hosts command group -│ │ ├── __init__.py -│ │ ├── setup.py # ~80 lines -│ │ ├── list.py # ~25 lines -│ │ ├── clear.py # ~20 lines -│ │ ├── add.py # ~35 lines -│ │ └── remove.py # ~25 lines -│ ├── forward.py # Standalone command (~40 lines) -│ └── shortcuts.py # Shortcuts (~40 lines) -├── core/ -│ ├── __init__.py -│ ├── config.py # SSMConfigManager -│ ├── session.py # SSMSession -│ └── port_forwarder.py # PortForwarder -└── utils/ - ├── __init__.py - └── hosts_manager.py # HostsManager -``` - -### ✅ Good: DynamoDB Structure -``` -cli_tool/commands/dynamodb/ -├── __init__.py -├── commands/ -│ ├── __init__.py -│ ├── export_table.py # Main export command -│ └── list_templates.py # Template management -├── core/ -│ ├── __init__.py -│ ├── exporter.py # Export logic -│ └── parallel_scanner.py # Scanning logic -└── utils/ - ├── __init__.py - ├── templates.py # Template management - └── filter_builder.py # Query building -``` - -### ❌ Bad: Mixed Structure at Root Level -``` -cli_tool/ -├── ssm/ # Feature -├── dynamodb/ # Feature -├── agents/ # Infrastructure -├── ui/ # Infrastructure -├── utils/ # Infrastructure -└── cli.py # Entry point -``` - -### ❌ Bad: Large Single File -``` -cli_tool/commands/ -└── ssm.py # 600+ lines, multiple concerns -``` - -### ❌ Bad: Missing Subdirectories for Command Groups -``` -cli_tool/commands/ssm/commands/ -├── database_connect.py # Should be database/connect.py -├── database_list.py # Should be database/list.py -├── instance_shell.py # Should be instance/shell.py -└── instance_list.py # Should be instance/list.py -``` - -## Testing Structure - -Tests should mirror the source structure: - -``` -tests/ -├── test_feature_name/ -│ ├── test_commands.py -│ ├── test_core.py -│ └── test_utils.py -└── test_simple_command.py -``` - -## Documentation - -Each feature module should have: - -1. **README.md** - Feature overview, usage examples -2. **Docstrings** - All public functions and classes -3. **Type hints** - All function signatures - -## Benefits - -1. **Consistency** - Easy to find code across features -2. **Maintainability** - Clear separation of concerns -3. **Testability** - Business logic isolated from CLI -4. **Scalability** - Easy to add new subcommands (just add new file) -5. **Onboarding** - New developers know where to look -6. **Small Files** - Each command file is 20-100 lines (easy to understand) -7. **Git Friendly** - Less merge conflicts with small, focused files -8. **Discoverability** - File structure mirrors CLI structure diff --git a/.kiro/steering/product.md b/.kiro/steering/product.md index 4d55f02..a9a7118 100644 --- a/.kiro/steering/product.md +++ b/.kiro/steering/product.md @@ -25,40 +25,64 @@ Devo CLI is a Python-based command-line tool that provides AI-powered developmen - Shell completion support (bash, zsh, fish) - Rich terminal UI for formatted output -## Command Reference - -### `devo commit` -Generates conventional commit messages from staged git changes. -- Format: `(): ` -- Extracts ticket numbers from branch names (feature/description) -- Types: feat, fix, chore, docs, refactor, test, style, perf -- Max 50 chars for summary line - -### `devo code-reviewer` -AI-powered code review analyzing git diffs. -- Reviews staged or committed changes -- Checks: code quality, security, best practices, performance -- Structured output with severity levels and actionable feedback - -### `devo aws-login` -Automates AWS SSO authentication and credential management. -- Interactive SSO profile configuration -- Browser-based authentication flow -- Automatic credential caching (8-12 hour expiration) -- Profile listing and verification -- Auto-refresh for expired/expiring credentials (--refresh-all) -- Detects both legacy SSO format and new sso-session format -- Groups profiles by SSO session to minimize login prompts -- Shows real expiration time in local timezone - -### `devo codeartifact-login` -Authenticates with AWS CodeArtifact for package management. -- Domain: devo-ride -- Repository: pypi -- Region: us-east-1 - -### `devo upgrade` -Self-updates the CLI tool to the latest version. +## Available Commands + +### AI-Powered Features +- **commit** - AI-powered commit message generation + - Conventional commit format + - Automatic ticket extraction from branch names + - Multi-line descriptions + - Interactive confirmation + +- **code-reviewer** - AI code review with security analysis + - Reviews staged or committed changes + - Checks: code quality, security, best practices, performance + - Structured output with severity levels + +### AWS Authentication +- **aws-login** - AWS SSO authentication and credential management + - Interactive SSO profile configuration + - Browser-based authentication flow + - Automatic credential caching (8-12 hour expiration) + - Auto-refresh for expired/expiring credentials + - Groups profiles by SSO session to minimize login prompts + +- **codeartifact-login** - CodeArtifact authentication + - 12-hour authentication tokens + - Automatic pip configuration + +### AWS Services +- **dynamodb** - DynamoDB table management + - List, describe, and export tables + - Multiple export formats (CSV, JSON, JSONL, TSV) + - Filter expressions with auto-optimization + - Parallel scanning and compression support + +- **eventbridge** - EventBridge rule management + - List rules with status + - Filter by environment and state + - Multiple output formats (table, JSON) + +- **ssm** - AWS Systems Manager Session Manager + - Secure shell access via SSM + - Database connection tunnels + - Port forwarding + - /etc/hosts management + +### Configuration & Tools +- **config** - Configuration management + - Nested key access with dot notation + - JSON export/import + - Configuration validation + +- **autocomplete** - Shell autocompletion setup + - Auto-detects current shell + - Supports bash, zsh, fish + +- **upgrade** - Self-update system + - Automatic version checking + - Platform-specific binaries + - Safe upgrade with backup ## Design Principles diff --git a/.kiro/steering/structure.md b/.kiro/steering/structure.md deleted file mode 100644 index 41dcf4e..0000000 --- a/.kiro/steering/structure.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -inclusion: always ---- - -# Project Structure & Architecture - -## Core Directory Structure - -All source code lives in `cli_tool/` package: - -- `cli_tool/cli.py` - CLI entry point, command registration with Click -- `cli_tool/config.py` - AWS Bedrock models, paths, configuration -- `cli_tool/_version.py` - Auto-generated from git tags (NEVER edit manually) -- `cli_tool/commands/` - Each CLI command as separate module -- `cli_tool/core/agents/` - AI agent base classes (BaseAgent wrapper) -- `cli_tool/core/ui/` - Rich-based terminal UI components -- `cli_tool/core/utils/` - Shared utilities (AWS, git) - -## Architecture Patterns - -### Adding New CLI Commands -1. Create module in `cli_tool/commands/` (e.g., `my_command.py`) -2. Define Click command with decorators for options/arguments -3. Register in `cli_tool/cli.py` using `cli.add_command()` -4. Follow existing command structure for consistency - -### AI Agent Pattern -- Extend `BaseAgent` from `cli_tool/core/agents/base_agent.py` -- Use Pydantic models for structured outputs -- Define system prompts inline or in dedicated prompt modules -- For complex features, create subdirectory with `prompt/` folder (see `code_reviewer/`) - -### Feature Modules -Large features get their own subdirectory under `cli_tool/commands/`: -- Main logic files (e.g., `analyzer.py`, `git_utils.py`) -- `prompt/` subdirectory for AI prompts -- `tools/` subdirectory for feature-specific tools -- Example: `cli_tool/commands/code_reviewer/` - -## Naming Conventions (REQUIRED) - -- Python modules: `snake_case` (e.g., `commit_prompt.py`) -- Classes: `PascalCase` (e.g., `BaseAgent`, `CommitMessageResponse`) -- Functions/methods: `snake_case` (e.g., `get_staged_diff()`) -- Constants: `UPPER_SNAKE_CASE` (e.g., `BEDROCK_MODEL_ID`) -- Private members: prefix `_` (e.g., `_internal_method`) - -## Git Workflow - -- Branch format: `feature/description` -- Commit format: `(): ` (max 50 chars) -- Types: feat, fix, chore, docs, refactor, test, style, perf -- Ticket numbers auto-extracted from branch names - -## Critical Files (DO NOT MODIFY) - -- `cli_tool/_version.py` - Auto-generated by setuptools_scm from git tags -- `.editorconfig` - Code style: 2 spaces, LF line endings -- `.flake8` - Line length: 150 chars - -## Testing - -- Tests in `tests/` directory -- Prefix test files with `test_` (e.g., `test_commit_prompt.py`) -- Use pytest and pytest-mock -- Tests should mirror package structure - -## Package Distribution - -- Entry point: `devo` console script defined in `setup.py` -- Version from git tags via setuptools_scm diff --git a/QUICKSTART.md b/QUICKSTART.md index b58df22..6f40c99 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,6 +1,114 @@ # Quick Start Guide -## For New Developers +## Installation + +### Binary Installation (Recommended) + +**Linux/macOS:** +```bash +curl -fsSL https://raw.githubusercontent.com/edu526/devo-cli/main/install.sh | bash +``` + +**Windows (PowerShell):** +```powershell +irm https://raw.githubusercontent.com/edu526/devo-cli/main/install.ps1 | iex +``` + +### Verify Installation + +```bash +devo --version +devo --help +``` + +## First Steps + +### 1. Configure AWS Credentials + +```bash +# If you don't have AWS CLI configured +aws configure + +# Or use AWS SSO +devo aws-login configure +devo aws-login +``` + +### 2. Setup Shell Autocompletion + +```bash +devo autocomplete --install +source ~/.bashrc # or ~/.zshrc +``` + +### 3. Try AI Features + +```bash +# Make some changes to your code +git add . + +# Generate commit message +devo commit + +# Review code changes +devo code-reviewer +``` + +## Common Workflows + +### Daily Development + +```bash +# 1. Make code changes +# ... edit files ... + +# 2. Stage changes +git add . + +# 3. Generate commit message and commit +devo commit + +# 4. Push changes +devo commit -p + +# 5. Create PR +devo commit -pr +``` + +### AWS Operations + +```bash +# Login to AWS +devo aws-login + +# Export DynamoDB table +devo dynamodb export my-table + +# Connect to database via SSM +devo ssm database connect my-db + +# Manage EventBridge rules +devo eventbridge list +devo eventbridge enable my-rule +``` + +### Configuration + +```bash +# View current config +devo config show + +# Change Bedrock model +devo config set bedrock.model_id us.anthropic.claude-sonnet-4-20250514-v1:0 + +# Change AWS region +devo config set aws.region us-west-2 + +# Backup configuration +devo config export ~/devo-config-backup.json +``` + +## For Developers ### One Command Setup @@ -28,7 +136,7 @@ devo make test ``` -## Daily Workflow +## Daily Development Workflow ```bash # 1. Make changes @@ -40,6 +148,12 @@ make refresh # 3. Test devo make test + +# 4. Lint +make lint + +# 5. Commit +devo commit ``` ## Common Commands @@ -49,6 +163,7 @@ make help # Show all available commands make refresh # Refresh after code changes make test # Run tests make lint # Check code style +make format # Format code devo commit # Generate commit message ``` @@ -57,19 +172,20 @@ devo commit # Generate commit message - Run `make help` for all commands - Check `docs/contributing.md` for detailed guide - See `README.md` for full documentation +- Visit [Full Documentation](https://edu526.github.io/devo-cli) -## First Time? +## Quick Command Reference -```bash -# 1. Clone repo -git clone -cd devo-cli +| Command | Description | +|---------|-------------| +| `devo commit` | AI commit message generation | +| `devo code-reviewer` | AI code review | +| `devo aws-login` | AWS SSO authentication | +| `devo dynamodb list` | List DynamoDB tables | +| `devo ssm database connect` | Connect to database | +| `devo config show` | Show configuration | +| `devo upgrade` | Update to latest version | -# 2. Run setup -chmod +x setup-dev.sh -./setup-dev.sh - -# 3. Start coding! -``` +See [Command Reference](./docs/commands.md) for complete list. -You're ready to contribute! 🚀 +You're ready to go! 🚀 diff --git a/README.md b/README.md index ebcd4e9..f29210b 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,16 @@ AI-powered command-line tool for developers with AWS Bedrock integration. - 📝 AI-powered commit message generation - 🤖 AI code review with security analysis +- 🔐 AWS SSO authentication and credential management +- 🗄️ DynamoDB table management and export utilities +- 📡 EventBridge rule management +- 🖥️ AWS Systems Manager Session Manager integration +- 📦 CodeArtifact authentication +- ⚙️ Configuration management system - 🔄 Self-updating capability - 📦 Standalone binaries (no Python required) - ⚡ Fast startup on macOS/Windows (optimized onedir builds) +- 🐚 Shell autocompletion support ## Quick Install @@ -28,25 +35,60 @@ irm https://raw.githubusercontent.com/edu526/devo-cli/main/install.ps1 | iex ## Usage +### AI-Powered Features + ```bash # AI commit message generation devo commit # AI code review devo code-reviewer --base-branch main +``` -# Update to latest version -devo upgrade +### AWS Integration -# CodeArtifact login +```bash +# AWS SSO login +devo aws-login +devo aws-login list +devo aws-login refresh + +# CodeArtifact authentication devo codeartifact-login +``` + +### AWS Services +```bash +# DynamoDB operations +devo dynamodb list +devo dynamodb export my-table --filter "userId = user123" + +# EventBridge rules +devo eventbridge list +devo eventbridge enable my-rule + +# SSM Session Manager +devo ssm database connect my-db +devo ssm instance shell i-1234567890abcdef0 +devo ssm forward my-service 8080 +``` + +### Configuration & Tools + +```bash # Configuration management devo config show devo config set bedrock.model_id us.anthropic.claude-sonnet-4-20250514-v1:0 +# Shell autocompletion +devo autocomplete --install + +# Update to latest version +devo upgrade + # Use specific AWS profile -devo --profile my-profile commit +devo --profile my-profile dynamodb list ``` ### Commit Command Options @@ -62,6 +104,60 @@ Options: --profile TEXT AWS profile to use ``` +## Available Commands + +### AI-Powered Features + +- `commit` - Generate conventional commit messages from staged changes +- `code-reviewer` - AI-powered code review with security analysis + +### AWS Authentication + +- `aws-login` - AWS SSO authentication and credential management + - `list` - List all profiles with status + - `login [PROFILE]` - Login to specific profile + - `refresh` - Refresh expired credentials + - `set-default [PROFILE]` - Set default profile + - `configure [PROFILE]` - Configure new SSO profile + +- `codeartifact-login` - Authenticate with AWS CodeArtifact + +### AWS Services + +- `dynamodb` - DynamoDB table management + - `list` - List all tables + - `describe TABLE` - Describe table structure + - `export TABLE` - Export table data (CSV, JSON, JSONL, TSV) + - `list-templates` - List saved export templates + +- `eventbridge` - EventBridge rule management + - `list` - List all rules + - `enable RULE` - Enable a rule + - `disable RULE` - Disable a rule + - `describe RULE` - Describe rule details + +- `ssm` - AWS Systems Manager Session Manager + - `database connect NAME` - Connect to RDS database via SSM + - `instance shell INSTANCE_ID` - Start shell session + - `forward SERVICE PORT` - Port forwarding + - `hosts setup` - Setup /etc/hosts entries + +### Configuration & Tools + +- `config` - Configuration management + - `show` - View current configuration + - `set KEY VALUE` - Set configuration value + - `get KEY` - Get configuration value + - `edit` - Open config in editor + - `export FILE` - Export configuration + - `import FILE` - Import configuration + - `reset` - Reset to defaults + +- `autocomplete` - Shell autocompletion setup + - `--install` - Automatically install completion + +- `upgrade` - Update to latest version + ## Configuration Configuration stored in `~/.devo/config.json`: @@ -140,8 +236,23 @@ Push to main triggers automated release with binaries for all platforms. 📚 **[Full Documentation](https://edu526.github.io/devo-cli)** -Quick links: +### User Guides - [Configuration Guide](./docs/configuration.md) +- [AWS Login Guide](./cli_tool/commands/aws_login/README.md) +- [DynamoDB Guide](./cli_tool/commands/dynamodb/README.md) +- [SSM Session Manager Guide](./cli_tool/commands/ssm/README.md) +- [EventBridge Guide](./cli_tool/commands/eventbridge/README.md) + +### Command References +- [commit](./cli_tool/commands/commit/README.md) - AI commit message generation +- [code-reviewer](./cli_tool/commands/code_reviewer/README.md) - AI code review +- [aws-login](./cli_tool/commands/aws_login/README.md) - AWS SSO authentication +- [codeartifact-login](./cli_tool/commands/codeartifact/README.md) - CodeArtifact auth +- [config](./cli_tool/commands/config_cmd/README.md) - Configuration management +- [autocomplete](./cli_tool/commands/autocomplete/README.md) - Shell completion +- [upgrade](./cli_tool/commands/upgrade/README.md) - Self-update system + +### Developer Guides - [Development Guide](./docs/development.md) - [CI/CD Pipeline](./docs/cicd.md) - [Semantic Release](./docs/semantic-release.md) diff --git a/cli_tool/commands/codeartifact/README.md b/cli_tool/commands/codeartifact/README.md new file mode 100644 index 0000000..b8a6928 --- /dev/null +++ b/cli_tool/commands/codeartifact/README.md @@ -0,0 +1,101 @@ +# CodeArtifact Authentication + +AWS CodeArtifact authentication for Python package management. + +## Structure + +``` +cli_tool/commands/codeartifact/ +├── __init__.py # Public API exports +├── README.md # This file +├── commands/ # CLI command definitions +│ ├── __init__.py # Command registration +│ └── login.py # Login command +└── core/ # Business logic + ├── __init__.py + └── authenticator.py # CodeArtifactAuthenticator +``` + +## Usage + +```bash +# Authenticate with CodeArtifact +devo codeartifact-login + +# Or use alias +devo ca-login + +# Use specific AWS profile +devo --profile production codeartifact-login +``` + +## Features + +- Automatic authentication with AWS CodeArtifact +- Configures pip to use CodeArtifact repository +- Token-based authentication (12-hour validity) +- Supports custom domain and repository configuration + +## Configuration + +Default configuration in `~/.devo/config.json`: + +```json +{ + "codeartifact": { + "domain": "devo-ride", + "repository": "pypi", + "region": "us-east-1" + } +} +``` + +## Architecture + +### Commands Layer (`commands/`) +- `login.py`: CLI command with Click decorators +- User feedback and error handling +- Output formatting with Rich + +### Core Layer (`core/`) +- `authenticator.py`: CodeArtifactAuthenticator class +- AWS CodeArtifact API integration +- Pip configuration management +- No Click dependencies + +## How It Works + +1. Retrieves authentication token from AWS CodeArtifact +2. Configures pip to use CodeArtifact repository +3. Sets up index URL with embedded token +4. Token valid for 12 hours + +## Requirements + +- AWS credentials configured +- AWS CodeArtifact permissions: + - `codeartifact:GetAuthorizationToken` + - `codeartifact:ReadFromRepository` + +## After Authentication + +Install packages from CodeArtifact: + +```bash +pip install your-private-package +``` + +Publish packages to CodeArtifact: + +```bash +python -m build +twine upload --repository codeartifact dist/* +``` + +## Token Expiration + +Tokens expire after 12 hours. Re-run the command to refresh: + +```bash +devo codeartifact-login +``` diff --git a/cli_tool/commands/commit/README.md b/cli_tool/commands/commit/README.md new file mode 100644 index 0000000..3766bb0 --- /dev/null +++ b/cli_tool/commands/commit/README.md @@ -0,0 +1,150 @@ +# Commit Message Generator + +AI-powered conventional commit message generation using AWS Bedrock. + +## Structure + +``` +cli_tool/commands/commit/ +├── __init__.py # Public API exports +├── README.md # This file +├── commands/ # CLI command definitions +│ ├── __init__.py # Command registration +│ └── generate.py # Main commit command +└── core/ # Business logic + ├── __init__.py + └── generator.py # CommitMessageGenerator +``` + +## Usage + +```bash +# Generate commit message from staged changes +devo commit + +# Add all changes and commit +devo commit -a + +# Commit and push +devo commit -p + +# Commit, push, and open PR +devo commit -pr + +# Do everything in sequence +devo commit -A + +# Use specific AWS profile +devo --profile production commit +``` + +## Options + +- `-a, --add` - Add all changes before committing +- `-p, --push` - Push to current branch after committing +- `-pr, --pull-request` - Open browser to create GitHub PR +- `-A, --all` - Execute add, commit, push, and PR in sequence +- `--profile TEXT` - AWS profile to use for Bedrock + +## Features + +- AI-powered commit message generation using Claude 3.7 Sonnet +- Conventional commit format (`(): `) +- Automatic ticket number extraction from branch names +- Multi-line descriptions for complex changes +- Interactive confirmation before committing +- Git workflow automation (add, commit, push, PR) + +## Commit Format + +Generated commits follow the Conventional Commits specification: + +``` +(): + + + +