From 091c1b710cbb7edb6a2cbb619105a4d5a7adfa1d Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Sun, 15 Mar 2026 14:05:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(cli,drone):=20CLI=20front=20door=20?= =?UTF-8?q?=E2=80=94=20seedgo-compliant=20discovery=20+=20drone=20internal?= =?UTF-8?q?=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI entry point rewritten to follow seedgo auto-discovery pattern: - discover_modules() scans for handle_command() instead of hardcoded list - Introspection shows routable modules vs import-only services - init_project.py now owns the 'aipass' command with subcommand routing - Rich colors fixed (force_terminal=True) for drone subprocess pipes CLI registered as drone internal module (drone_adapter.py): - drone @cli accessible from any directory, not just inside AIPass repo - drone @cli aipass init works from external projects - Drone introspection now shows 3 internal modules: cli, drone, seedgo Drone: CLI added to interactive allow list for terminal passthrough. Co-Authored-By: @devpulse --- src/aipass/cli/apps/cli.py | 431 +++++++----------- src/aipass/cli/apps/modules/display.py | 4 +- src/aipass/cli/apps/modules/init_project.py | 271 +++++++---- src/aipass/cli/drone_adapter.py | 173 +++++++ src/aipass/drone/README.md | 10 +- src/aipass/drone/apps/drone.py | 8 +- .../apps/handlers/module_registry_handler.py | 1 + 7 files changed, 542 insertions(+), 356 deletions(-) create mode 100644 src/aipass/cli/drone_adapter.py diff --git a/src/aipass/cli/apps/cli.py b/src/aipass/cli/apps/cli.py index 51c9c69c..4da48682 100755 --- a/src/aipass/cli/apps/cli.py +++ b/src/aipass/cli/apps/cli.py @@ -1,16 +1,18 @@ # =================== AIPass ==================== # Name: cli.py -# Description: Entry point CLI for drone @cli — display service and Rich formatting -# Version: 1.0.0 +# Description: Entry point for drone @cli — seedgo-compliant module discovery and routing +# Version: 2.0.0 # Created: 2026-03-08 -# Modified: 2026-03-08 +# Modified: 2026-03-15 # ============================================= """ CLI Branch - Universal Display/Output Service Provider -PURPOSE: Provides consistent CLI display across all AIPass branches. -Similar to Prax (logging service), CLI is a service provider for output. +Routes commands to auto-discovered modules via seedgo pattern. +- 'aipass init /path' -> init_project module (handle_command) +- Modules auto-discovered from modules/ directory +- Service modules (display, templates) also discoverable via handle_command() SERVICES PROVIDED: - Display: headers, success/error/warning messages, sections @@ -23,9 +25,9 @@ ARCHITECTURE: """ import sys -import importlib +import importlib.util from pathlib import Path -from typing import List +from typing import List, Any # Prax logger from aipass.prax.apps.modules.logger import system_logger as logger @@ -34,96 +36,161 @@ from aipass.prax.apps.modules.logger import system_logger as logger from rich.table import Table from rich.columns import Columns from rich.panel import Panel -from rich.text import Text -from rich.rule import Rule from rich import box # CLI modules (showcasing our own services!) from aipass.cli.apps.modules.display import console as CONSOLE, header, success, error, warning, section -from aipass.cli.apps.modules.templates import operation_start, operation_complete + +VERSION = "2.0.0" +CLI_ROOT = Path(__file__).parent +MODULES_DIR = CLI_ROOT / "modules" + +# Service modules — import-only, listed separately from command modules +SERVICE_MODULES = {"display", "templates"} # ============================================================================= -# INTROSPECTION DISPLAY +# MODULE DISCOVERY # ============================================================================= -def get_handler_exports(handler_package_name: str) -> List[str]: - """Get exported functions from handler package""" - try: - handler_module = importlib.import_module(f"aipass.cli.apps.handlers.{handler_package_name}") - return getattr(handler_module, '__all__', []) - except Exception: - return [] +def discover_modules() -> List[Any]: + """Auto-discover CLI modules in modules/ directory. - -def print_introspection(): + Scans modules/*.py for files with handle_command(). + Excludes __init__.py and private files. + Follows seedgo discovery pattern exactly. """ - Display discovered modules and handlers - Quick reference + modules = [] + + if not MODULES_DIR.exists(): + return modules + + for file_path in sorted(MODULES_DIR.glob("*.py")): + if file_path.name.startswith("_"): + continue + + try: + spec = importlib.util.spec_from_file_location(file_path.stem, file_path) + if spec is None or spec.loader is None: + continue + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + if hasattr(module, "handle_command"): + modules.append(module) + except Exception as e: + logger.error(f"[CLI] Failed to load module {file_path.stem}: {e}") + + return modules + + +def route_command(command: str, args: List[str], modules: List[Any]) -> bool: + """Route command to appropriate module.""" + for module in modules: + try: + if module.handle_command(command, args): + return True + except Exception as e: + logger.error(f"[CLI] Module error: {e}") + return False + + +# ============================================================================= +# DISPLAY +# ============================================================================= + +def print_introspection() -> None: + """Display auto-discovered modules — seedgo-compliant introspection. Called when cli.py runs with no arguments. - Shows what's connected to CLI entry point (modules and handler domains). + Shows command modules (with handle_command) and service modules separately. """ + modules = discover_modules() + + # Separate command modules from service modules + command_modules = [m for m in modules + if getattr(m, "__name__", "").split(".")[-1] not in SERVICE_MODULES] + service_modules = [m for m in modules + if getattr(m, "__name__", "").split(".")[-1] in SERVICE_MODULES] + CONSOLE.print() CONSOLE.print("[bold cyan]CLI - Command Line Interface Branch[/bold cyan]") + CONSOLE.print(f" Version: {VERSION}") CONSOLE.print() CONSOLE.print("[dim]Universal Display & Output Service Provider[/dim]") CONSOLE.print() - # Discover modules - CONSOLE.print("[yellow]Discovered Modules:[/yellow] 3") + # Discovered command modules + CONSOLE.print(f"[yellow]Discovered Modules:[/yellow] {len(command_modules)}") + for module in command_modules: + name = getattr(module, "__name__", "unknown").split(".")[-1] + desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" + CONSOLE.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}") + if not command_modules: + CONSOLE.print(" [dim]No command modules discovered[/dim]") CONSOLE.print() - CONSOLE.print(" [cyan]•[/cyan] display") - CONSOLE.print(" [cyan]•[/cyan] templates") - CONSOLE.print(" [cyan]•[/cyan] console (Rich library wrapper)") - CONSOLE.print() - CONSOLE.print("[dim]Run 'python3 cli.py --help' for usage information[/dim]") + + # Service modules (import-only, but with utility commands) + if service_modules: + CONSOLE.print(f"[yellow]Services:[/yellow] {len(service_modules)}") + for module in service_modules: + name = getattr(module, "__name__", "unknown").split(".")[-1] + desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" + CONSOLE.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}") + CONSOLE.print() + + CONSOLE.print("[yellow]Next:[/yellow] Explore a module") + CONSOLE.print(" [green]drone @cli aipass[/green] [dim]# Project commands[/dim]") + CONSOLE.print(" [green]drone @cli aipass init --help[/green] [dim]# Bootstrap a project[/dim]") + CONSOLE.print(" [green]drone @cli --help[/green] [dim]# Full usage guide[/dim]") CONSOLE.print() -def print_help(): +def print_help() -> None: """Display Rich-formatted help - CLI services showcase! - Demonstrates: - - How to import and use CLI modules - - Rich formatting patterns (headers, tables, panels, columns) - - Drone compliance with Commands line - - Beautiful terminal presentation + Shows COMMANDS, EXAMPLES, full reference. + Follows seedgo help pattern. """ - CONSOLE.print() - # ============================================================================= - # SECTION 1: MAIN HEADER - # Demonstrates: header() function from cli.apps.modules.display - # ============================================================================= header("CLI - Display & Templates Service Provider") CONSOLE.print("[dim]Universal display and output formatting for all AIPass branches[/dim]") CONSOLE.print() - CONSOLE.print("─" * 70) + CONSOLE.print("\u2500" * 70) CONSOLE.print() - # ============================================================================= - # SECTION 2: WHAT IS CLI? - # Demonstrates: Rich text styling with [bold], [cyan], [green], etc. - # ============================================================================= + # What is CLI CONSOLE.print("[bold cyan]WHAT IS CLI?[/bold cyan]") CONSOLE.print() CONSOLE.print("CLI is the [bold]Display & Templates Service[/bold] - like Prax for logging:") - CONSOLE.print(" [green]✓[/green] Centralized display formatting (headers, tables, panels)") - CONSOLE.print(" [green]✓[/green] Reusable templates for common operations") - CONSOLE.print(" [green]✓[/green] Rich library integration for beautiful output") - CONSOLE.print(" [green]✓[/green] Consistent styling across all AIPass branches") + CONSOLE.print(" [green]\u2713[/green] Centralized display formatting (headers, tables, panels)") + CONSOLE.print(" [green]\u2713[/green] Reusable templates for common operations") + CONSOLE.print(" [green]\u2713[/green] Rich library integration for beautiful output") + CONSOLE.print(" [green]\u2713[/green] Consistent styling across all AIPass branches") CONSOLE.print() - CONSOLE.print("Update CLI once → All branches instantly benefit from improvements") + CONSOLE.print("Update CLI once \u2192 All branches instantly benefit from improvements") CONSOLE.print() - CONSOLE.print("─" * 70) + CONSOLE.print("\u2500" * 70) CONSOLE.print() - # ============================================================================= - # SECTION 3: PUBLIC SERVICES - # Demonstrates: Table() from rich.table with columns, styling - # ============================================================================= + # Commands + CONSOLE.print("[bold cyan]COMMANDS:[/bold cyan]") + CONSOLE.print() + CONSOLE.print(" [green]drone @cli[/green] [dim]# Show discovered modules[/dim]") + CONSOLE.print(" [green]drone @cli aipass[/green] [dim]# Project commands[/dim]") + CONSOLE.print(" [green]drone @cli aipass init[/green] [dim]# Bootstrap a project[/dim]") + CONSOLE.print(" [green]drone @cli aipass init /path MyProj[/green] [dim]# Bootstrap with name[/dim]") + CONSOLE.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]") + CONSOLE.print(" [green]drone @cli display demo[/green] [dim]# Run display demo[/dim]") + CONSOLE.print(" [green]drone @cli templates[/green] [dim]# Templates module info[/dim]") + CONSOLE.print(" [green]drone @cli templates demo[/green] [dim]# Run templates demo[/dim]") + CONSOLE.print(" [green]drone @cli --help[/green] [dim]# This help message[/dim]") + CONSOLE.print() + CONSOLE.print("\u2500" * 70) + CONSOLE.print() + + # Public services CONSOLE.print("[bold cyan]PUBLIC SERVICES (apps/modules/):[/bold cyan]") CONSOLE.print() @@ -145,13 +212,10 @@ def print_help(): CONSOLE.print(services_table) CONSOLE.print() - CONSOLE.print("─" * 70) + CONSOLE.print("\u2500" * 70) CONSOLE.print() - # ============================================================================= - # SECTION 4: IMPORT EXAMPLES - # Demonstrates: Code examples with [dim] styling and [yellow] for labels - # ============================================================================= + # Import examples CONSOLE.print("[bold cyan]HOW TO IMPORT CLI SERVICES:[/bold cyan]") CONSOLE.print() @@ -164,257 +228,88 @@ def print_help(): CONSOLE.print() CONSOLE.print("[yellow]Rich console:[/yellow]") - CONSOLE.print("[dim] from rich.console import Console[/dim]") - CONSOLE.print("[dim] CONSOLE = Console() # Access Rich library directly[/dim]") + CONSOLE.print("[dim] from aipass.cli.apps.modules.display import console[/dim]") + CONSOLE.print("[dim] console.print('[bold]Hello[/bold]') # Rich formatted output[/dim]") CONSOLE.print() - CONSOLE.print("─" * 70) + CONSOLE.print("\u2500" * 70) CONSOLE.print() - # ============================================================================= - # SECTION 5: USAGE EXAMPLES - # Demonstrates: Columns() for side-by-side layout - # ============================================================================= - CONSOLE.print("[bold cyan]USAGE EXAMPLES:[/bold cyan]") - CONSOLE.print() - - example_cols = [ - "[yellow]Display header:[/yellow]\n[dim]header('Create Branch',\n {'Name': 'feature',\n 'Type': 'module'})[/dim]", - "[yellow]Show success:[/yellow]\n[dim]success('Files created',\n items=12,\n time='2.3s')[/dim]", - "[yellow]Show error:[/yellow]\n[dim]error('Path not found',\n suggestion='Check spelling')[/dim]" - ] - - CONSOLE.print(Columns(example_cols, equal=True, expand=True)) - CONSOLE.print() - CONSOLE.print("─" * 70) - CONSOLE.print() - - # ============================================================================= - # SECTION 6: ARCHITECTURE - # Demonstrates: Panel() for highlighted content blocks - # ============================================================================= + # Architecture CONSOLE.print("[bold cyan]ARCHITECTURE:[/bold cyan]") CONSOLE.print() arch_text = """[bold]CLI Branch Structure:[/bold] -[green]✓[/green] apps/modules/ = PUBLIC API (what branches import) +[green]\u2713[/green] apps/modules/ = PUBLIC API (what branches import) - display.py Display functions (header, success, error, etc.) - templates.py Standard operation patterns + - init_project.py Project bootstrap (aipass init) -[green]✓[/green] apps/handlers/ = PRIVATE (internal implementation) +[green]\u2713[/green] apps/handlers/ = PRIVATE (internal implementation) - display/ Header, message formatters - templates/ Operation patterns + - init/ Bootstrap logic -[green]✓[/green] Rich library = Underlying formatting engine +[green]\u2713[/green] Rich library = Underlying formatting engine - Console, Table, Panel, Columns, Text styling""" CONSOLE.print(Panel(arch_text, border_style="green", padding=(1, 2), box=box.ROUNDED)) CONSOLE.print() - CONSOLE.print("─" * 70) + CONSOLE.print("\u2500" * 70) CONSOLE.print() - # ============================================================================= - # SECTION 8: FOR BRANCHES USING CLI - # Demonstrates: Service provider pattern explanation - # ============================================================================= - CONSOLE.print("[bold cyan]FOR BRANCHES USING CLI:[/bold cyan]") - CONSOLE.print() - - workflow_text = """[bold]Replace custom display code with CLI imports:[/bold] - - [green]✓[/green] Displaying headers? → [dim]from aipass.cli.apps.modules.display import header[/dim] - [green]✓[/green] Showing success/errors? → [dim]from aipass.cli.apps.modules.display import success, error[/dim] - [green]✓[/green] Operation templates? → [dim]from aipass.cli.apps.modules.templates import operation_*[/dim] - [green]✓[/green] Rich formatting? → [dim]from rich.console import Console[/dim] - -[bold]Benefits:[/bold] - • No code duplication across branches - • Consistent formatting system-wide - • Update CLI once → affects all branches instantly - • Rich library integration done once, used everywhere""" - - CONSOLE.print(Panel(workflow_text, border_style="green", padding=(1, 2), box=box.ROUNDED)) - CONSOLE.print() - CONSOLE.print("─" * 70) - CONSOLE.print() - - # ============================================================================= - # SECTION 9: QUICK REFERENCE - # Demonstrates: Using both CONSOLE.print() and module functions - # ============================================================================= - CONSOLE.print("[bold cyan]QUICK REFERENCE:[/bold cyan]") - CONSOLE.print() - - quick_ref = Table(show_header=True, header_style="bold cyan", border_style="dim") - quick_ref.add_column("Task", style="green") - quick_ref.add_column("CLI Function", style="yellow") - quick_ref.add_column("Example", style="dim") - - quick_ref.add_row( - "Display title", - "header()", - "header('Task Complete')" - ) - quick_ref.add_row( - "Success message", - "success()", - "success('Created', items=5)" - ) - quick_ref.add_row( - "Error message", - "error()", - "error('Failed', suggestion='Retry')" - ) - quick_ref.add_row( - "Warning message", - "warning()", - "warning('Be careful')" - ) - quick_ref.add_row( - "Section break", - "section()", - "section('Results')" - ) - quick_ref.add_row( - "Operation start", - "operation_start()", - "operation_start('Process', count=10)" - ) - quick_ref.add_row( - "Operation end", - "operation_complete()", - "operation_complete(created=5, failed=0)" - ) - - CONSOLE.print(quick_ref) - CONSOLE.print() - CONSOLE.print("─" * 70) - CONSOLE.print() - - # ============================================================================= - # SECTION 10: FULL DOCUMENTATION - # ============================================================================= - CONSOLE.print("[bold cyan]FULL DOCUMENTATION:[/bold cyan]") - CONSOLE.print() - _cli_root = Path(__file__).resolve().parents[1] # cli.py -> apps -> cli - CONSOLE.print(f" [yellow]Source code:[/yellow] [dim]{_cli_root / 'apps'}[/dim]") - CONSOLE.print(f" [yellow]Public API:[/yellow] [dim]{_cli_root / 'apps' / 'modules'}[/dim]") - CONSOLE.print(f" [yellow]Implementation:[/yellow] [dim]{_cli_root / 'apps' / 'handlers'}[/dim]") - CONSOLE.print() - CONSOLE.print("─" * 70) - CONSOLE.print() - - # ============================================================================= - # SECTION 11: RICH FORMATTING TIPS - # This section itself demonstrates Rich formatting! - # ============================================================================= - CONSOLE.print("[bold cyan]RICH FORMATTING TIPS:[/bold cyan]") - CONSOLE.print() - CONSOLE.print(" [yellow]Text styles:[/yellow] [bold]bold[/bold], [dim]dim[/dim], [italic]italic[/italic], [underline]underline[/underline]") - CONSOLE.print(" [yellow]Colors:[/yellow] [red]red[/red], [green]green[/green], [yellow]yellow[/yellow], [blue]blue[/blue], [cyan]cyan[/cyan]") - CONSOLE.print(" [yellow]Icons:[/yellow] ✓ ✅ ❌ ⚠️ ⚙️ → • — ─") - CONSOLE.print(" [yellow]Structures:[/yellow] Table, Panel, Columns, Text, Progress") - CONSOLE.print() - CONSOLE.print("─" * 70) - CONSOLE.print() - - # ============================================================================= - # SECTION 12: DRONE COMPLIANCE - # CRITICAL: Commands line must be present for drone discovery - # ============================================================================= - CONSOLE.print("[dim]Commands: help, --help, -h[/dim]") + # Drone compliance — commands line + CONSOLE.print("[dim]Commands: aipass, display, templates, demo, --help[/dim]") CONSOLE.print() def show_version(): - """Print version from META DATA HEADER.""" - CONSOLE.print("CLI v0.2.0") + """Print version.""" + CONSOLE.print(f"CLI v{VERSION}") -def _handle_aipass(args): - """Route aipass subcommands.""" - if not args or (args[0] in ("--help", "-h", "help")): - _print_aipass_introspection() - return +# ============================================================================= +# MAIN ENTRY POINT +# ============================================================================= - subcmd = args[0] - sub_args = args[1:] +def main() -> int: + """Main entry point - routes to modules.""" + modules = discover_modules() + args = sys.argv[1:] - from aipass.cli.apps.modules.init_project import handle_command - if handle_command(subcmd, sub_args): - return - - error(f"Unknown aipass subcommand: {subcmd}", suggestion="drone @cli aipass --help") - sys.exit(1) - - -def _print_aipass_introspection(): - """Show available aipass subcommands — discovery for new users.""" - from rich.table import Table - - CONSOLE.print() - header("aipass — Project Commands") - CONSOLE.print("[dim]Manage AIPass projects from the command line[/dim]") - CONSOLE.print() - - table = Table(show_header=True, header_style="bold cyan", border_style="dim") - table.add_column("Command", style="green") - table.add_column("Description", style="white") - table.add_column("Example", style="dim") - - table.add_row( - "init", - "Bootstrap a new AIPass project", - "drone @cli aipass init /path MyProject", - ) - - CONSOLE.print(table) - CONSOLE.print() - CONSOLE.print("[dim]Run [bold]drone @cli aipass init --help[/bold] for detailed usage[/dim]") - CONSOLE.print() - CONSOLE.print("[dim]Commands: init, --help[/dim]") - CONSOLE.print() - - -def main(): - """CLI branch entry point - shows available services""" - - # Show introspection when run without arguments - if len(sys.argv) == 1: + # No args -> introspection (discovery mode) + if not args: print_introspection() - return + return 0 - # Handle version flag - if len(sys.argv) > 1 and sys.argv[1] in ['--version', '-V']: - show_version() - return - - # Handle help flags - if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + # Help flag -> full help with usage + if args[0] in ["--help", "-h", "help"]: print_help() - return + return 0 - # Route subcommands - command = sys.argv[1] - cmd_args = sys.argv[2:] if len(sys.argv) > 2 else [] + if args[0] in ["--version", "-V"]: + show_version() + return 0 - if command == "aipass": - _handle_aipass(cmd_args) - return + command = args[0] + remaining = args[1:] if len(args) > 1 else [] + + # Route to modules + if route_command(command, remaining, modules): + return 0 - # Unknown command error(f"Unknown command: {command}", suggestion="Run 'drone @cli --help' for usage") - sys.exit(1) + return 1 if __name__ == "__main__": try: - main() + sys.exit(main()) except KeyboardInterrupt: CONSOLE.print("\n[yellow]Operation cancelled[/yellow]") sys.exit(0) except Exception as e: logger.error(f"CLI error: {e}", exc_info=True) - CONSOLE.print(f"\n[red]❌ Error: {e}[/red]") + CONSOLE.print(f"\n[red]Error: {e}[/red]") sys.exit(1) diff --git a/src/aipass/cli/apps/modules/display.py b/src/aipass/cli/apps/modules/display.py index 6be98483..f93f78ce 100755 --- a/src/aipass/cli/apps/modules/display.py +++ b/src/aipass/cli/apps/modules/display.py @@ -33,9 +33,9 @@ from rich.columns import Columns # from aipass.prax import logger # Initialize Rich console (lowercase follows service instance pattern) -CONSOLE = Console() # Internal constant +CONSOLE = Console(force_terminal=True) # Internal constant — force_terminal ensures ANSI colors even when piped console = CONSOLE # Primary export (lowercase service instance pattern) -err_console = Console(stderr=True) # Stderr console for error/warning output +err_console = Console(stderr=True, force_terminal=True) # Stderr console for error/warning output # Trigger loaded lazily to avoid circular import _trigger = None diff --git a/src/aipass/cli/apps/modules/init_project.py b/src/aipass/cli/apps/modules/init_project.py index 1d353239..111449aa 100644 --- a/src/aipass/cli/apps/modules/init_project.py +++ b/src/aipass/cli/apps/modules/init_project.py @@ -1,19 +1,18 @@ # =================== AIPass ==================== # Name: init_project.py -# Description: Init Project Module — orchestration layer for aipass init -# Version: 1.0.0 +# Description: AIPass Project Commands Module — owns the 'aipass' top-level command +# Version: 2.0.0 # Created: 2026-03-14 -# Modified: 2026-03-14 +# Modified: 2026-03-15 # ============================================= """ -Init Project Module - PUBLIC API +AIPass Project Commands Module -Thin orchestration module for the `aipass init` command. -Routes to the init bootstrap handler for business logic. +Owns the 'aipass' top-level command and routes subcommands (init, etc.). +Follows seedgo module interface: handle_command(), print_introspection(), print_help(). -Usage: - drone @cli aipass init [target_dir] [project_name] +Run: drone @cli aipass """ import os @@ -30,25 +29,201 @@ from aipass.cli.apps.modules.display import console, success, error, header # ============================================================================= def print_introspection(): - """Display module info and connected handlers.""" + """Display aipass command info — available subcommands and connected handlers.""" + from rich.table import Table + console.print() - console.print("[bold cyan]Init Project Module[/bold cyan]") + console.print("[bold cyan]aipass — Project Commands[/bold cyan]") + console.print("[dim]Manage AIPass projects from the command line[/dim]") console.print() - console.print("[dim]Bootstrap an AIPass project in any directory[/dim]") + + # Available subcommands + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Command", style="green") + table.add_column("Description", style="white") + table.add_column("Example", style="dim") + + table.add_row( + "init", + "Bootstrap a new AIPass project", + "drone @cli aipass init /path MyProject", + ) + + console.print(table) console.print() console.print("[yellow]Connected Handlers:[/yellow]") - console.print() console.print(" [cyan]handlers/init/[/cyan]") console.print(" [dim]- bootstrap.py[/dim]") console.print() - console.print("[dim]Run 'drone @cli aipass init --help' for usage[/dim]") + console.print("[yellow]Next:[/yellow] Run a subcommand") + console.print(" [green]drone @cli aipass init[/green] [dim]# Bootstrap a project[/dim]") + console.print(" [green]drone @cli aipass init --help[/green] [dim]# Detailed init usage[/dim]") + console.print(" [green]drone @cli aipass --help[/green] [dim]# Full help[/dim]") console.print() def print_help(): - """Display Rich-formatted help for the init command.""" + """Display Rich-formatted help for the aipass command and all subcommands.""" + from rich.panel import Panel + from rich.table import Table + from rich import box + + console.print() + header("aipass — Project Commands") + console.print("[dim]Manage AIPass projects from the command line[/dim]") + console.print() + console.print("─" * 70) + console.print() + + # Subcommands + console.print("[bold cyan]COMMANDS:[/bold cyan]") + console.print() + console.print(" [green]drone @cli aipass[/green] [dim]Show available subcommands[/dim]") + console.print(" [green]drone @cli aipass init[/green] [dim]Bootstrap in current directory[/dim]") + console.print(" [green]drone @cli aipass init /path[/green] [dim]Bootstrap in target directory[/dim]") + console.print(" [green]drone @cli aipass init /path MyProj[/green] [dim]Bootstrap with custom name[/dim]") + console.print(" [green]drone @cli aipass --help[/green] [dim]This help message[/dim]") + console.print() + console.print("─" * 70) + console.print() + + # What init creates + console.print("[bold cyan]WHAT INIT CREATES:[/bold cyan]") + console.print() + + files_text = """[bold]Project scaffold (6 files):[/bold] + + [green]1.[/green] [yellow]{NAME}_REGISTRY.json[/yellow] Project registry with UUID + [green]2.[/green] [yellow].trinity/passport.json[/yellow] Project identity (with registry_id) + [green]3.[/green] [yellow].trinity/local.json[/yellow] Session history & learnings + [green]4.[/green] [yellow].trinity/observations.json[/yellow] Collaboration patterns + [green]5.[/green] [yellow].aipass/aipass_local_prompt.md[/yellow] Local prompt (injected every turn) + [green]6.[/green] [yellow]AIPASS.md[/yellow] Project prompt (persists in context)""" + + console.print(Panel(files_text, border_style="green", padding=(1, 2), box=box.ROUNDED)) + console.print() + console.print("─" * 70) + console.print() + + # Arguments + console.print("[bold cyan]ARGUMENTS (init):[/bold cyan]") + console.print() + + args_table = Table(show_header=True, header_style="bold cyan", border_style="dim") + args_table.add_column("Argument", style="green") + args_table.add_column("Required", style="yellow") + args_table.add_column("Default", style="dim") + args_table.add_column("Description", style="white") + args_table.add_row("target_dir", "No", "Current directory", "Directory to initialize") + args_table.add_row("project_name", "No", "Directory name", "Name for registry (auto-uppercased)") + console.print(args_table) + console.print() + console.print("─" * 70) + console.print() + + console.print("[dim]Commands: aipass, aipass init, aipass --help[/dim]") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """Handle 'aipass' command with subcommand routing. + + Args: + command: The command string (e.g. "aipass") + args: Remaining arguments after the command + [] -> show introspection (available subcommands) + ["init"] -> run init + ["init", "--help"] -> show init help + ["--help"] -> show full help + + Returns: + True if handled, False if not this module's command + """ + if command != "aipass": + return False + + # No subcommand -> show introspection + if not args: + print_introspection() + return True + + # Help flag -> show full help + if args[0] in ("--help", "-h", "help"): + print_help() + return True + + subcmd = args[0] + sub_args = args[1:] + + # Route subcommands + if subcmd == "init": + return _handle_init(sub_args) + + error(f"Unknown aipass subcommand: {subcmd}", suggestion="drone @cli aipass --help") + return True + + +def _handle_init(args: List[str]) -> bool: + """Handle the 'init' subcommand.""" + from rich.panel import Panel + from rich.table import Table + from rich import box + + # Handle help flag + if args and args[0] in ("--help", "-h", "help"): + _print_init_help() + return True + + # Parse positional args: [target_dir] [project_name] + caller_cwd = os.environ.get("AIPASS_CALLER_CWD", os.getcwd()) + target = Path(args[0]) if args else Path(caller_cwd) + project_name = args[1] if len(args) > 1 else None + + try: + result = init_project(target, project_name) + except ValueError as exc: + error(str(exc), suggestion="Pass a project name explicitly") + sys.exit(1) + except FileExistsError as exc: + error(str(exc), suggestion="Remove the existing file to re-initialize") + sys.exit(1) + except OSError as exc: + error(f"Filesystem error: {exc}") + sys.exit(1) + + # Display results + console.print() + header("Project Initialized") + + # Summary panel + summary = ( + f"[bold]{result['project_name']}[/bold]\n" + f"\n" + f" [yellow]Registry:[/yellow] {result['registry_file']}\n" + f" [yellow]ID:[/yellow] [dim]{result['registry_id'][:8]}...[/dim]\n" + f" [yellow]Target:[/yellow] [dim]{result['target']}[/dim]" + ) + console.print(Panel(summary, border_style="green", box=box.ROUNDED)) + + # Files table + files_table = Table(show_header=True, header_style="bold cyan", border_style="dim") + files_table.add_column("#", style="green", width=3) + files_table.add_column("File", style="yellow") + for i, f in enumerate(result["created_files"], 1): + files_table.add_row(str(i), f) + console.print(files_table) + console.print() + + success(f"Created {len(result['created_files'])} files") + console.print() + + return True + + +def _print_init_help(): + """Display detailed help for the init subcommand.""" from rich.panel import Panel from rich.table import Table from rich import box @@ -113,74 +288,6 @@ def print_help(): console.print() -def handle_command(command: str, args: List[str]) -> bool: - """Handle 'init' command. - - Args: - command: The subcommand string (e.g. "init") - args: Remaining positional arguments after the subcommand - - Returns: - True if the command was handled, False otherwise - """ - if command != "init": - return False - - # Handle help flag - if args and args[0] in ("--help", "-h", "help"): - print_help() - return True - - # Parse positional args: [target_dir] [project_name] - caller_cwd = os.environ.get("AIPASS_CALLER_CWD", os.getcwd()) - target = Path(args[0]) if args else Path(caller_cwd) - project_name = args[1] if len(args) > 1 else None - - try: - result = init_project(target, project_name) - except ValueError as exc: - error(str(exc), suggestion="Pass a project name explicitly") - sys.exit(1) - except FileExistsError as exc: - error(str(exc), suggestion="Remove the existing file to re-initialize") - sys.exit(1) - except OSError as exc: - error(f"Filesystem error: {exc}") - sys.exit(1) - - # Display results - from rich.panel import Panel - from rich.table import Table - from rich import box - - console.print() - header("Project Initialized") - - # Summary panel - summary = ( - f"[bold]{result['project_name']}[/bold]\n" - f"\n" - f" [yellow]Registry:[/yellow] {result['registry_file']}\n" - f" [yellow]ID:[/yellow] [dim]{result['registry_id'][:8]}...[/dim]\n" - f" [yellow]Target:[/yellow] [dim]{result['target']}[/dim]" - ) - console.print(Panel(summary, border_style="green", box=box.ROUNDED)) - - # Files table - files_table = Table(show_header=True, header_style="bold cyan", border_style="dim") - files_table.add_column("#", style="green", width=3) - files_table.add_column("File", style="yellow") - for i, f in enumerate(result["created_files"], 1): - files_table.add_row(str(i), f) - console.print(files_table) - console.print() - - success(f"Created {len(result['created_files'])} files") - console.print() - - return True - - # ============================================================================= # MODULE EXPORTS # ============================================================================= diff --git a/src/aipass/cli/drone_adapter.py b/src/aipass/cli/drone_adapter.py new file mode 100644 index 00000000..d14795f2 --- /dev/null +++ b/src/aipass/cli/drone_adapter.py @@ -0,0 +1,173 @@ +""" +CLI drone adapter — bridges drone routing to CLI commands. + +Drone discovers this module via aipass.drone.modules._MODULE_REGISTRY +and routes `drone @cli [args]` here. +""" + +import sys +from io import StringIO + +DRONE_MODULE = { + "name": "cli", + "version": "2.0.0", + "description": "Universal Display & Output Service Provider", +} + + +def handle_command(command: str, args: list[str] | None = None) -> dict: + """Route a drone command to CLI's entry point. + + Captures stdout/stderr and returns as dict for drone CLI to print. + """ + if args is None: + args = [] + + # Build argv as if `cli [args]` was called + original_argv = sys.argv + old_stdout = sys.stdout + old_stderr = sys.stderr + captured_out = StringIO() + captured_err = StringIO() + + try: + sys.argv = ["cli", command] + args + sys.stdout = captured_out + sys.stderr = captured_err + + # Import here to avoid circular imports at module level + from aipass.cli.apps.cli import main + exit_code = main() + except SystemExit as e: + exit_code = e.code if e.code is not None else 0 + except Exception as e: + captured_err.write(str(e)) + exit_code = 1 + finally: + sys.argv = original_argv + sys.stdout = old_stdout + sys.stderr = old_stderr + + return { + "stdout": captured_out.getvalue(), + "stderr": captured_err.getvalue(), + "exit_code": exit_code if isinstance(exit_code, int) else 1, + } + + +def get_help(command: str | None = None) -> str: + """Return help text for CLI as Rich markup strings. + + Returns Rich markup (not captured ANSI) so drone's console.print() + renders it cleanly — same pattern as get_introspective(). + """ + if command: + result = handle_command(command, ["--help"]) + return result.get("stdout", "") or result.get("stderr", "") + + # Build help as Rich markup strings (drone renders these) + try: + from aipass.cli.apps.cli import discover_modules, VERSION + modules = discover_modules() + except Exception: + return "cli — Universal Display & Output Service Provider\nRun 'drone @cli --help' for usage\n" + + lines = [] + lines.append("") + lines.append("[bold cyan]CLI - Display & Templates Service Provider[/bold cyan]") + lines.append(f" Version: {VERSION}") + lines.append("") + lines.append("[dim]Universal display and output formatting for all AIPass branches[/dim]") + lines.append("") + lines.append("\u2500" * 70) + lines.append("") + + # What is CLI + lines.append("[bold cyan]WHAT IS CLI?[/bold cyan]") + lines.append("") + lines.append("CLI is the [bold]Display & Templates Service[/bold] \u2014 it:") + lines.append(" [green]\u2713[/green] Provides [green]centralized display formatting[/green] (headers, tables, panels)") + lines.append(" [green]\u2713[/green] Reusable templates for common operations") + lines.append(" [green]\u2713[/green] Rich library integration for beautiful output") + lines.append(" [green]\u2713[/green] Consistent styling across all AIPass branches") + lines.append("") + + # Discovered modules + if modules: + lines.append("[bold cyan]DISCOVERED MODULES:[/bold cyan]") + lines.append("") + for module in modules: + name = getattr(module, "__name__", "unknown").split(".")[-1] + desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" + lines.append(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}") + lines.append("") + + lines.append("\u2500" * 70) + lines.append("") + + # Usage + lines.append("[bold cyan]USAGE:[/bold cyan]") + lines.append("") + lines.append("[yellow]Commands:[/yellow]") + lines.append(" [dim]drone @cli # Show discovered modules[/dim]") + lines.append(" [dim]drone @cli aipass # Project commands[/dim]") + lines.append(" [dim]drone @cli aipass init # Bootstrap a project[/dim]") + lines.append(" [dim]drone @cli aipass init /path MyProj # Bootstrap with name[/dim]") + lines.append(" [dim]drone @cli display # Display module info[/dim]") + lines.append(" [dim]drone @cli display demo # Run display demo[/dim]") + lines.append(" [dim]drone @cli --help # Full usage guide[/dim]") + lines.append("") + + lines.append("\u2500" * 70) + lines.append("") + + # Commands line for drone discovery + lines.append("[dim]Commands: aipass, display, templates, demo, --help[/dim]") + lines.append("") + + return "\n".join(lines) + + +def get_introspective() -> str: + """Discovery mode: show what CLI has connected.""" + try: + from aipass.cli.apps.cli import discover_modules, VERSION, SERVICE_MODULES + + modules = discover_modules() + + # Separate command modules from service modules + command_modules = [m for m in modules + if getattr(m, "__name__", "").split(".")[-1] not in SERVICE_MODULES] + service_modules = [m for m in modules + if getattr(m, "__name__", "").split(".")[-1] in SERVICE_MODULES] + + lines = [] + lines.append("[bold cyan]CLI - Display & Templates Service Provider[/bold cyan]") + lines.append(f" Version: {VERSION}") + lines.append("") + + # Command modules + lines.append(f"[yellow]Discovered Modules:[/yellow] {len(command_modules)}") + for module in command_modules: + mod_name = getattr(module, "__name__", "unknown").split(".")[-1] + desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" + lines.append(f" [cyan]\u2022[/cyan] {mod_name} \u2014 {desc}") + if not command_modules: + lines.append(" [dim]No command modules discovered[/dim]") + lines.append("") + + # Service modules + if service_modules: + lines.append(f"[yellow]Services:[/yellow] {len(service_modules)}") + for module in service_modules: + mod_name = getattr(module, "__name__", "unknown").split(".")[-1] + desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" + lines.append(f" [cyan]\u2022[/cyan] {mod_name} \u2014 {desc}") + lines.append("") + + lines.append("[dim]Run 'drone @cli --help' for usage[/dim]") + lines.append("") + + return "\n".join(lines) + except Exception: + return "@cli \u2014 Universal Display & Output Service Provider (run 'drone @cli --help' for usage)\n" diff --git a/src/aipass/drone/README.md b/src/aipass/drone/README.md index 18f0b2e2..6bcba90f 100644 --- a/src/aipass/drone/README.md +++ b/src/aipass/drone/README.md @@ -105,7 +105,7 @@ By default, drone captures subprocess output (`capture_output=True`) with a 30s Commands in the interactive tuple bypass capture and inherit the terminal directly — enabling live Rich output, colors, and no timeout. Only add commands here when Patrick needs full terminal experience. -**Current interactive commands** (in `apps/drone.py`): +**Per-command allowlist** (in `apps/drone.py`): | Command | Reason | |--------------|---------------------------------------------| @@ -113,7 +113,13 @@ Commands in the interactive tuple bypass capture and inherit the terminal direct | `snapshot` | Backup snapshot (Rich progress bars) | | `versioned` | Backup versioned (Rich progress, long-running) | -To add a new command: edit the `interactive` tuple in `_handle_target()` in `apps/drone.py`. +**Per-branch allowlist** — all commands from these branches get interactive mode: + +| Branch | Reason | +|--------|---------------------------------------------| +| `cli` | User-facing CLI with Rich formatted output | + +To add: edit `interactive_commands` or `interactive_branches` in `_handle_target()` in `apps/drone.py`. --- diff --git a/src/aipass/drone/apps/drone.py b/src/aipass/drone/apps/drone.py index 8bfc031f..957a090f 100644 --- a/src/aipass/drone/apps/drone.py +++ b/src/aipass/drone/apps/drone.py @@ -209,8 +209,12 @@ def _handle_target(args: List[str]) -> int: command = rest[0] cmd_args = rest[1:] - # Long-running interactive commands bypass capture + timeout - interactive = command in ("monitor", "snapshot", "versioned") + # Interactive mode bypasses capture + timeout for human-facing output. + # Per-command: specific commands that need live terminal (progress bars, TUI). + # Per-branch: all commands from that branch get interactive mode (Rich CLI). + interactive_commands = ("monitor", "snapshot", "versioned") + interactive_branches = ("cli",) + interactive = command in interactive_commands or module_name in interactive_branches try: result = route_command( diff --git a/src/aipass/drone/apps/handlers/module_registry_handler.py b/src/aipass/drone/apps/handlers/module_registry_handler.py index ac0d1511..3625e193 100644 --- a/src/aipass/drone/apps/handlers/module_registry_handler.py +++ b/src/aipass/drone/apps/handlers/module_registry_handler.py @@ -23,6 +23,7 @@ from aipass.prax import logger # Maps module name -> import path for its drone_adapter _MODULE_REGISTRY: dict[str, str] = { + "cli": "aipass.cli.drone_adapter", "drone": "aipass.drone.drone_adapter", "seedgo": "aipass.seedgo.drone_adapter", }