diff --git a/pyproject.toml b/pyproject.toml index 7f648ae..a20b1c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,10 @@ classifiers = [ keywords = ["claude", "ai", "automation", "framework", "mcp", "agents", "development", "code-generation", "assistant"] dependencies = [ "setuptools>=45.0.0", - "importlib-metadata>=1.0.0; python_version<'3.8'" + "importlib-metadata>=1.0.0; python_version<'3.8'", + "typer>=0.9.0", + "rich>=13.0.0", + "click>=8.0.0" ] [project.urls] @@ -43,8 +46,8 @@ GitHub = "https://github.com/SuperClaude-Org/SuperClaude_Framework" "NomenAK" = "https://github.com/NomenAK" [project.scripts] -SuperClaude = "superclaude.__main__:main" -superclaude = "superclaude.__main__:main" +SuperClaude = "superclaude.cli.app:cli_main" +superclaude = "superclaude.cli.app:cli_main" [project.optional-dependencies] dev = [ diff --git a/superclaude/cli/__init__.py b/superclaude/cli/__init__.py new file mode 100644 index 0000000..58a9206 --- /dev/null +++ b/superclaude/cli/__init__.py @@ -0,0 +1,5 @@ +""" +SuperClaude CLI - Modern typer + rich based command-line interface +""" + +__all__ = ["app", "console"] diff --git a/superclaude/cli/_console.py b/superclaude/cli/_console.py new file mode 100644 index 0000000..1bd2e75 --- /dev/null +++ b/superclaude/cli/_console.py @@ -0,0 +1,8 @@ +""" +Shared Rich console instance for consistent formatting across CLI commands +""" + +from rich.console import Console + +# Single console instance for all CLI operations +console = Console() diff --git a/superclaude/cli/app.py b/superclaude/cli/app.py new file mode 100644 index 0000000..60a0cc9 --- /dev/null +++ b/superclaude/cli/app.py @@ -0,0 +1,70 @@ +""" +SuperClaude CLI - Root application with typer +Modern, type-safe command-line interface with rich formatting +""" + +import sys +import typer +from typing import Optional +from superclaude.cli._console import console +from superclaude.cli.commands import install, doctor, config + +# Create root typer app +app = typer.Typer( + name="superclaude", + help="SuperClaude Framework CLI - AI-enhanced development framework for Claude Code", + add_completion=False, # Disable shell completion for now + no_args_is_help=True, # Show help when no args provided + pretty_exceptions_enable=True, # Rich exception formatting +) + +# Register command groups +app.add_typer(install.app, name="install", help="Install SuperClaude components") +app.add_typer(doctor.app, name="doctor", help="Diagnose system environment") +app.add_typer(config.app, name="config", help="Manage configuration") + + +def version_callback(value: bool): + """Show version and exit""" + if value: + from setup.cli.base import __version__ + console.print(f"[bold cyan]SuperClaude[/bold cyan] version [green]{__version__}[/green]") + raise typer.Exit() + + +@app.callback() +def main( + version: Optional[bool] = typer.Option( + None, + "--version", + "-v", + callback=version_callback, + is_eager=True, + help="Show version and exit", + ), +): + """ + SuperClaude Framework CLI + + Modern command-line interface for managing SuperClaude installation, + configuration, and diagnostic operations. + """ + pass + + +def cli_main(): + """Entry point for CLI (called from pyproject.toml)""" + try: + app() + except KeyboardInterrupt: + console.print("\n[yellow]Operation cancelled by user[/yellow]") + sys.exit(130) + except Exception as e: + console.print(f"[bold red]Unhandled error:[/bold red] {e}") + if "--debug" in sys.argv or "--verbose" in sys.argv: + console.print_exception() + sys.exit(1) + + +if __name__ == "__main__": + cli_main() diff --git a/superclaude/cli/commands/__init__.py b/superclaude/cli/commands/__init__.py new file mode 100644 index 0000000..bb3c3b0 --- /dev/null +++ b/superclaude/cli/commands/__init__.py @@ -0,0 +1,5 @@ +""" +SuperClaude CLI commands +""" + +__all__ = [] diff --git a/superclaude/cli/commands/config.py b/superclaude/cli/commands/config.py new file mode 100644 index 0000000..592d27b --- /dev/null +++ b/superclaude/cli/commands/config.py @@ -0,0 +1,268 @@ +""" +SuperClaude config command - Configuration management with API key validation +""" + +import re +import typer +import os +from typing import Optional +from pathlib import Path +from rich.prompt import Prompt, Confirm +from rich.table import Table +from rich.panel import Panel +from superclaude.cli._console import console + +app = typer.Typer(name="config", help="Manage SuperClaude configuration") + +# API key validation patterns (P0: basic validation, P1: enhanced with Pydantic) +API_KEY_PATTERNS = { + "OPENAI_API_KEY": { + "pattern": r"^sk-[A-Za-z0-9]{20,}$", + "description": "OpenAI API key (sk-...)", + }, + "ANTHROPIC_API_KEY": { + "pattern": r"^sk-ant-[A-Za-z0-9_-]{20,}$", + "description": "Anthropic API key (sk-ant-...)", + }, + "TAVILY_API_KEY": { + "pattern": r"^tvly-[A-Za-z0-9_-]{20,}$", + "description": "Tavily API key (tvly-...)", + }, +} + + +def validate_api_key(key_name: str, key_value: str) -> tuple[bool, Optional[str]]: + """ + Validate API key format + + Args: + key_name: Environment variable name + key_value: API key value to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if key_name not in API_KEY_PATTERNS: + # Unknown key type - skip validation + return True, None + + pattern_info = API_KEY_PATTERNS[key_name] + pattern = pattern_info["pattern"] + + if not re.match(pattern, key_value): + return False, f"Invalid format. Expected: {pattern_info['description']}" + + return True, None + + +@app.command("set") +def set_config( + key: str = typer.Argument(..., help="Configuration key (e.g., OPENAI_API_KEY)"), + value: Optional[str] = typer.Argument(None, help="Configuration value"), + interactive: bool = typer.Option( + True, + "--interactive/--non-interactive", + help="Prompt for value if not provided", + ), +): + """ + Set a configuration value with validation + + Supports API keys for: + - OPENAI_API_KEY: OpenAI API access + - ANTHROPIC_API_KEY: Anthropic Claude API access + - TAVILY_API_KEY: Tavily search API access + + Examples: + superclaude config set OPENAI_API_KEY + superclaude config set TAVILY_API_KEY tvly-abc123... + """ + console.print( + Panel.fit( + f"[bold cyan]Setting configuration:[/bold cyan] {key}", + border_style="cyan", + ) + ) + + # Get value if not provided + if value is None: + if not interactive: + console.print("[red]Value required in non-interactive mode[/red]") + raise typer.Exit(1) + + # Interactive prompt + is_secret = "KEY" in key.upper() or "TOKEN" in key.upper() + + if is_secret: + value = Prompt.ask( + f"Enter value for {key}", + password=True, # Hide input + ) + else: + value = Prompt.ask(f"Enter value for {key}") + + # Validate if it's a known API key + is_valid, error_msg = validate_api_key(key, value) + + if not is_valid: + console.print(f"[red]Validation failed:[/red] {error_msg}") + + if interactive: + retry = Confirm.ask("Try again?", default=True) + if retry: + # Recursive retry + set_config(key, None, interactive=True) + return + raise typer.Exit(2) + + # Save to environment (in real implementation, save to config file) + # For P0, we'll just set the environment variable + os.environ[key] = value + + console.print(f"[green]✓ Configuration saved:[/green] {key}") + + # Show next steps + if key in API_KEY_PATTERNS: + console.print("\n[cyan]Next steps:[/cyan]") + console.print(f" • The {key} is now configured") + console.print(" • Restart Claude Code to apply changes") + console.print(f" • Verify with: [bold]superclaude config show {key}[/bold]") + + +@app.command("show") +def show_config( + key: Optional[str] = typer.Argument(None, help="Specific key to show"), + show_values: bool = typer.Option( + False, + "--show-values", + help="Show actual values (masked by default for security)", + ), +): + """ + Show configuration values + + By default, sensitive values (API keys) are masked. + Use --show-values to display actual values (use with caution). + + Examples: + superclaude config show + superclaude config show OPENAI_API_KEY + superclaude config show --show-values + """ + console.print( + Panel.fit( + "[bold cyan]SuperClaude Configuration[/bold cyan]", + border_style="cyan", + ) + ) + + # Get all API key environment variables + api_keys = {} + for key_name in API_KEY_PATTERNS.keys(): + value = os.environ.get(key_name) + if value: + api_keys[key_name] = value + + # Filter to specific key if requested + if key: + if key in api_keys: + api_keys = {key: api_keys[key]} + else: + console.print(f"[yellow]{key} is not configured[/yellow]") + return + + if not api_keys: + console.print("[yellow]No API keys configured[/yellow]") + console.print("\n[cyan]Configure API keys with:[/cyan]") + console.print(" superclaude config set OPENAI_API_KEY") + console.print(" superclaude config set TAVILY_API_KEY") + return + + # Create table + table = Table(title="\nConfigured API Keys", show_header=True, header_style="bold cyan") + table.add_column("Key", style="cyan", width=25) + table.add_column("Value", width=40) + table.add_column("Status", width=15) + + for key_name, value in api_keys.items(): + # Mask value unless explicitly requested + if show_values: + display_value = value + else: + # Show first 4 and last 4 characters + if len(value) > 12: + display_value = f"{value[:4]}...{value[-4:]}" + else: + display_value = "***" + + # Validate + is_valid, _ = validate_api_key(key_name, value) + status = "[green]✓ Valid[/green]" if is_valid else "[red]✗ Invalid[/red]" + + table.add_row(key_name, display_value, status) + + console.print(table) + + if not show_values: + console.print("\n[dim]Values are masked. Use --show-values to display actual values.[/dim]") + + +@app.command("validate") +def validate_config( + key: Optional[str] = typer.Argument(None, help="Specific key to validate"), +): + """ + Validate configuration values + + Checks API key formats for correctness. + Does not verify that keys are active/working. + + Examples: + superclaude config validate + superclaude config validate OPENAI_API_KEY + """ + console.print( + Panel.fit( + "[bold cyan]Validating Configuration[/bold cyan]", + border_style="cyan", + ) + ) + + # Get API keys to validate + api_keys = {} + if key: + value = os.environ.get(key) + if value: + api_keys[key] = value + else: + console.print(f"[yellow]{key} is not configured[/yellow]") + return + else: + # Validate all known API keys + for key_name in API_KEY_PATTERNS.keys(): + value = os.environ.get(key_name) + if value: + api_keys[key_name] = value + + if not api_keys: + console.print("[yellow]No API keys to validate[/yellow]") + return + + # Validate each key + all_valid = True + for key_name, value in api_keys.items(): + is_valid, error_msg = validate_api_key(key_name, value) + + if is_valid: + console.print(f"[green]✓[/green] {key_name}: Valid format") + else: + console.print(f"[red]✗[/red] {key_name}: {error_msg}") + all_valid = False + + # Summary + if all_valid: + console.print("\n[bold green]✓ All API keys have valid formats[/bold green]") + else: + console.print("\n[bold yellow]⚠ Some API keys have invalid formats[/bold yellow]") + console.print("[dim]Use [bold]superclaude config set [/bold] to update[/dim]") + raise typer.Exit(1) diff --git a/superclaude/cli/commands/doctor.py b/superclaude/cli/commands/doctor.py new file mode 100644 index 0000000..a9ccb8c --- /dev/null +++ b/superclaude/cli/commands/doctor.py @@ -0,0 +1,214 @@ +""" +SuperClaude doctor command - System diagnostics and environment validation +""" + +import typer +import sys +import shutil +from pathlib import Path +from rich.table import Table +from rich.panel import Panel +from superclaude.cli._console import console + +app = typer.Typer(name="doctor", help="Diagnose system environment and installation", invoke_without_command=True) + + +def run_diagnostics() -> dict: + """ + Run comprehensive system diagnostics + + Returns: + Dict with diagnostic results: {check_name: {status: bool, message: str}} + """ + results = {} + + # Check Python version + python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + python_ok = sys.version_info >= (3, 8) + results["Python Version"] = { + "status": python_ok, + "message": f"{python_version} {'✓' if python_ok else '✗ Requires Python 3.8+'}", + } + + # Check installation directory + install_dir = Path.home() / ".claude" + install_exists = install_dir.exists() + results["Installation Directory"] = { + "status": install_exists, + "message": f"{install_dir} {'exists' if install_exists else 'not found'}", + } + + # Check write permissions + try: + test_file = install_dir / ".write_test" + if install_dir.exists(): + test_file.touch() + test_file.unlink() + write_ok = True + write_msg = "Writable" + else: + write_ok = False + write_msg = "Directory does not exist" + except Exception as e: + write_ok = False + write_msg = f"No write permission: {e}" + + results["Write Permissions"] = { + "status": write_ok, + "message": write_msg, + } + + # Check disk space (500MB minimum) + try: + stat = shutil.disk_usage(install_dir.parent if install_dir.exists() else Path.home()) + free_mb = stat.free / (1024 * 1024) + disk_ok = free_mb >= 500 + results["Disk Space"] = { + "status": disk_ok, + "message": f"{free_mb:.1f} MB free {'✓' if disk_ok else '✗ Need 500+ MB'}", + } + except Exception as e: + results["Disk Space"] = { + "status": False, + "message": f"Could not check: {e}", + } + + # Check for required tools + tools = { + "git": "Git version control", + "uv": "UV package manager (recommended)", + } + + for tool, description in tools.items(): + tool_path = shutil.which(tool) + results[f"{description}"] = { + "status": tool_path is not None, + "message": f"{tool_path if tool_path else 'Not found'}", + } + + # Check SuperClaude components + if install_dir.exists(): + components = { + "CLAUDE.md": "Core framework entry point", + "MCP_*.md": "MCP documentation files", + "MODE_*.md": "Behavioral mode files", + } + + claude_md = install_dir / "CLAUDE.md" + results["Core Framework"] = { + "status": claude_md.exists(), + "message": "Installed" if claude_md.exists() else "Not installed", + } + + # Count MCP docs + mcp_docs = list(install_dir.glob("MCP_*.md")) + results["MCP Documentation"] = { + "status": len(mcp_docs) > 0, + "message": f"{len(mcp_docs)} servers documented" if mcp_docs else "None installed", + } + + # Count modes + mode_files = list(install_dir.glob("MODE_*.md")) + results["Behavioral Modes"] = { + "status": len(mode_files) > 0, + "message": f"{len(mode_files)} modes installed" if mode_files else "None installed", + } + + return results + + +@app.callback(invoke_without_command=True) +def run( + ctx: typer.Context, + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Show detailed diagnostic information", + ) +): + """ + Run system diagnostics and check environment + + This command validates your system environment and verifies + SuperClaude installation status. It checks: + - Python version compatibility + - File system permissions + - Available disk space + - Required tools (git, uv) + - Installed SuperClaude components + """ + if ctx.invoked_subcommand is not None: + return + console.print( + Panel.fit( + "[bold cyan]SuperClaude System Diagnostics[/bold cyan]\n" + "[dim]Checking system environment and installation status[/dim]", + border_style="cyan", + ) + ) + + # Run diagnostics + results = run_diagnostics() + + # Create rich table + table = Table(title="\nDiagnostic Results", show_header=True, header_style="bold cyan") + table.add_column("Check", style="cyan", width=30) + table.add_column("Status", width=10) + table.add_column("Details", style="dim") + + # Add rows + all_passed = True + for check_name, result in results.items(): + status = result["status"] + message = result["message"] + + if status: + status_str = "[green]✓ PASS[/green]" + else: + status_str = "[red]✗ FAIL[/red]" + all_passed = False + + table.add_row(check_name, status_str, message) + + console.print(table) + + # Summary and recommendations + if all_passed: + console.print( + "\n[bold green]✓ All checks passed![/bold green] " + "Your system is ready for SuperClaude." + ) + console.print("\n[cyan]Next steps:[/cyan]") + console.print(" • Use [bold]superclaude install all[/bold] if not yet installed") + console.print(" • Start using SuperClaude commands in Claude Code") + else: + console.print( + "\n[bold yellow]⚠ Some checks failed[/bold yellow] " + "Please address the issues below:" + ) + + # Specific recommendations + console.print("\n[cyan]Recommendations:[/cyan]") + + if not results["Python Version"]["status"]: + console.print(" • Upgrade Python to version 3.8 or higher") + + if not results["Installation Directory"]["status"]: + console.print(" • Run [bold]superclaude install all[/bold] to install framework") + + if not results["Write Permissions"]["status"]: + console.print(f" • Ensure write permissions for {Path.home() / '.claude'}") + + if not results["Disk Space"]["status"]: + console.print(" • Free up at least 500 MB of disk space") + + if not results.get("Git version control", {}).get("status"): + console.print(" • Install Git: https://git-scm.com/downloads") + + if not results.get("UV package manager (recommended)", {}).get("status"): + console.print(" • Install UV: https://docs.astral.sh/uv/") + + console.print("\n[dim]After addressing issues, run [bold]superclaude doctor[/bold] again[/dim]") + + raise typer.Exit(1) diff --git a/superclaude/cli/commands/install.py b/superclaude/cli/commands/install.py new file mode 100644 index 0000000..7d0eac9 --- /dev/null +++ b/superclaude/cli/commands/install.py @@ -0,0 +1,204 @@ +""" +SuperClaude install command - Modern interactive installation with rich UI +""" + +import typer +from typing import Optional, List +from pathlib import Path +from rich.panel import Panel +from rich.prompt import Confirm +from rich.progress import Progress, SpinnerColumn, TextColumn +from superclaude.cli._console import console + +# Create install command group +app = typer.Typer(name="install", help="Install SuperClaude framework components") + + +@app.command("all") +def install_all( + non_interactive: bool = typer.Option( + False, + "--non-interactive", + "-y", + help="Non-interactive installation with default configuration", + ), + profile: Optional[str] = typer.Option( + None, + "--profile", + help="Installation profile: api (with API keys), noapi (without), or custom", + ), + install_dir: Path = typer.Option( + Path.home() / ".claude", + "--install-dir", + help="Installation directory", + ), + force: bool = typer.Option( + False, + "--force", + help="Force reinstallation of existing components", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Simulate installation without making changes", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Verbose output with detailed logging", + ), +): + """ + Install SuperClaude with all recommended components + + This command installs the complete SuperClaude framework including: + - Core framework files and documentation + - Behavioral modes (7 modes) + - Slash commands (26 commands) + - Specialized agents (17 agents) + - MCP server integrations (optional) + """ + # Display installation header + console.print( + Panel.fit( + "[bold cyan]SuperClaude Framework Installer[/bold cyan]\n" + "[dim]Modern AI-enhanced development framework for Claude Code[/dim]", + border_style="cyan", + ) + ) + + # Confirm installation if interactive + if not non_interactive and not dry_run: + proceed = Confirm.ask( + "\n[bold]Install SuperClaude with recommended configuration?[/bold]", + default=True, + ) + if not proceed: + console.print("[yellow]Installation cancelled by user[/yellow]") + raise typer.Exit(0) + + # Import and run existing installer logic + # This bridges to the existing setup/cli/commands/install.py implementation + try: + from setup.cli.commands.install import run + import argparse + + # Create argparse namespace for backward compatibility + args = argparse.Namespace( + install_dir=install_dir, + force=force, + dry_run=dry_run, + verbose=verbose, + quiet=False, + yes=non_interactive, + components=["core", "modes", "commands", "agents", "mcp_docs"], # Full install + no_backup=False, + list_components=False, + diagnose=False, + ) + + # Show progress with rich spinner + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + transient=False, + ) as progress: + task = progress.add_task("Installing SuperClaude...", total=None) + + # Run existing installer + exit_code = run(args) + + if exit_code == 0: + progress.update(task, description="[green]Installation complete![/green]") + console.print("\n[bold green]✓ SuperClaude installed successfully![/bold green]") + console.print("\n[cyan]Next steps:[/cyan]") + console.print(" 1. Restart your Claude Code session") + console.print(f" 2. Framework files are now available in {install_dir}") + console.print(" 3. Use SuperClaude commands and features in Claude Code") + else: + progress.update(task, description="[red]Installation failed[/red]") + console.print("\n[bold red]✗ Installation failed[/bold red]") + console.print("[yellow]Check logs for details[/yellow]") + raise typer.Exit(1) + + except ImportError as e: + console.print(f"[bold red]Error:[/bold red] Could not import installer: {e}") + console.print("[yellow]Ensure SuperClaude is properly installed[/yellow]") + raise typer.Exit(1) + except Exception as e: + console.print(f"[bold red]Unexpected error:[/bold red] {e}") + if verbose: + console.print_exception() + raise typer.Exit(1) + + +@app.command("components") +def install_components( + components: List[str] = typer.Argument( + ..., + help="Component names to install (e.g., core modes commands agents)", + ), + install_dir: Path = typer.Option( + Path.home() / ".claude", + "--install-dir", + help="Installation directory", + ), + force: bool = typer.Option( + False, + "--force", + help="Force reinstallation", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Simulate installation", + ), +): + """ + Install specific SuperClaude components + + Available components: + - core: Core framework files and documentation + - modes: Behavioral modes (7 modes) + - commands: Slash commands (26 commands) + - agents: Specialized agents (17 agents) + - mcp: MCP server integrations + - mcp_docs: MCP documentation + """ + console.print( + Panel.fit( + f"[bold]Installing components:[/bold] {', '.join(components)}", + border_style="cyan", + ) + ) + + try: + from setup.cli.commands.install import run + import argparse + + args = argparse.Namespace( + install_dir=install_dir, + force=force, + dry_run=dry_run, + verbose=False, + quiet=False, + yes=True, # Non-interactive for component installation + components=components, + no_backup=False, + list_components=False, + diagnose=False, + ) + + exit_code = run(args) + + if exit_code == 0: + console.print(f"\n[bold green]✓ Components installed: {', '.join(components)}[/bold green]") + else: + console.print("\n[bold red]✗ Component installation failed[/bold red]") + raise typer.Exit(1) + + except Exception as e: + console.print(f"[bold red]Error:[/bold red] {e}") + raise typer.Exit(1) diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py new file mode 100644 index 0000000..4a595b8 --- /dev/null +++ b/tests/test_cli_smoke.py @@ -0,0 +1,126 @@ +""" +Smoke tests for new typer + rich CLI +Tests basic functionality without full integration +""" + +import pytest +from typer.testing import CliRunner +from superclaude.cli.app import app + +runner = CliRunner() + + +class TestCLISmoke: + """Basic smoke tests for CLI functionality""" + + def test_help_command(self): + """Test that --help works""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "SuperClaude" in result.stdout + assert "install" in result.stdout + assert "doctor" in result.stdout + assert "config" in result.stdout + + def test_version_command(self): + """Test that --version works""" + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "SuperClaude" in result.stdout + assert "version" in result.stdout + + def test_install_help(self): + """Test install command help""" + result = runner.invoke(app, ["install", "--help"]) + assert result.exit_code == 0 + assert "install" in result.stdout.lower() + + def test_install_all_help(self): + """Test install all subcommand help""" + result = runner.invoke(app, ["install", "all", "--help"]) + assert result.exit_code == 0 + assert "Install SuperClaude" in result.stdout + + def test_doctor_help(self): + """Test doctor command help""" + result = runner.invoke(app, ["doctor", "--help"]) + assert result.exit_code == 0 + assert "diagnose" in result.stdout.lower() or "diagnostic" in result.stdout.lower() + + def test_doctor_run(self): + """Test doctor command execution (may fail or pass depending on environment)""" + result = runner.invoke(app, ["doctor"]) + # Don't assert exit code - depends on environment + # Just verify it runs without crashing + assert "Diagnostic" in result.stdout or "System" in result.stdout + + def test_config_help(self): + """Test config command help""" + result = runner.invoke(app, ["config", "--help"]) + assert result.exit_code == 0 + assert "config" in result.stdout.lower() + + def test_config_show(self): + """Test config show command""" + result = runner.invoke(app, ["config", "show"]) + # Should not crash, may show "No API keys configured" + assert result.exit_code == 0 or "not configured" in result.stdout + + def test_config_validate(self): + """Test config validate command""" + result = runner.invoke(app, ["config", "validate"]) + # Should not crash + assert result.exit_code in (0, 1) # May exit 1 if no keys configured + + +class TestCLIIntegration: + """Integration tests for command workflows""" + + def test_doctor_install_workflow(self): + """Test doctor → install suggestion workflow""" + # Run doctor + doctor_result = runner.invoke(app, ["doctor"]) + + # Should suggest installation if not installed + # Or show success if already installed + assert doctor_result.exit_code in (0, 1) + + @pytest.mark.slow + def test_install_dry_run(self): + """Test installation in dry-run mode (safe, no changes)""" + result = runner.invoke(app, [ + "install", "all", + "--dry-run", + "--non-interactive" + ]) + + # Dry run should succeed or fail gracefully + assert result.exit_code in (0, 1) + if result.exit_code == 0: + # Should mention "dry run" or "would install" + assert "dry" in result.stdout.lower() or "would" in result.stdout.lower() + + +@pytest.mark.skipif( + not __name__ == "__main__", + reason="Manual test - run directly to test CLI interactively" +) +def test_manual_cli(): + """ + Manual test for CLI interaction + Run this file directly: python tests/test_cli_smoke.py + """ + print("\n=== Manual CLI Test ===") + print("Testing help command...") + result = runner.invoke(app, ["--help"]) + print(result.stdout) + + print("\nTesting doctor command...") + result = runner.invoke(app, ["doctor"]) + print(result.stdout) + + print("\nManual test complete!") + + +if __name__ == "__main__": + test_manual_cli()