diff --git a/README.md b/README.md index eacd478..3ebdb14 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Configure your preferred LLM provider. Supports both cloud providers (OpenAI, An ```bash how setup ``` +*(Features a keyboard-navigable selection menu for an easier first-time setup experience! Use your arrow keys to navigate and `Enter` to select.)* **Non-Interactive Mode**: ```bash diff --git a/how/actions.py b/how/actions.py index 6d2b04a..f49bfdc 100644 --- a/how/actions.py +++ b/how/actions.py @@ -5,6 +5,7 @@ from rich.console import Console from rich.panel import Panel from rich.prompt import Prompt +import questionary from how.core.safety import assess_risk @@ -82,7 +83,7 @@ def execute_commands(commands: list[str]) -> int: if not check_safety_gate(commands): return 1 - if not typer.confirm("Are you sure you want to execute these commands?"): + if not questionary.confirm("Are you sure you want to execute these commands?").ask(): console.print("[yellow]Execution cancelled.[/yellow]") return 0 @@ -95,10 +96,10 @@ def execute_commands(commands: list[str]) -> int: f"[bold red]Command failed with exit code {proc.returncode}[/bold red]" ) if idx < len(commands): - continue_run = typer.confirm( + continue_run = questionary.confirm( "Do you want to continue running the remaining commands?", default=False, - ) + ).ask() if not continue_run: console.print("[yellow]Execution stopped.[/yellow]") return proc.returncode @@ -130,7 +131,9 @@ def hook() -> None: readline.set_startup_hook(hook) edited = input("> ") except Exception: # noqa: BLE001 - edited = Prompt.ask("Edit command", default=joined) + edited = questionary.text("Edit command:", default=joined).ask() + if edited is None: + edited = joined finally: with contextlib.suppress(Exception): import readline @@ -151,18 +154,20 @@ def interactive_action_menu(commands: list[str]) -> None: while True: try: - console.print( - "\n[bold]Options:[/bold] " - "[bold green][E]xecute[/bold green] | " - "[bold cyan][C]opy[/bold cyan] | " - "[bold yellow][M]odify[/bold yellow] | " - "[bold red][A]bort[/bold red]" - ) - choice = Prompt.ask( - "Select an action", - choices=["e", "c", "m", "a", "E", "C", "M", "A"], - default="a", - ).lower() + choice = questionary.select( + "Select an action:", + choices=[ + questionary.Choice("Execute commands", "e"), + questionary.Choice("Copy to clipboard", "c"), + questionary.Choice("Modify command(s)", "m"), + questionary.Choice("Abort", "a"), + ], + default="a" + ).ask() + + if choice is None: + console.print("\n[yellow]Aborted.[/yellow]") + break if choice == "e": execute_commands(current_commands) diff --git a/how/how.py b/how/how.py index d68d721..12d5c5c 100644 --- a/how/how.py +++ b/how/how.py @@ -247,28 +247,46 @@ def setup( selected_model = model or provider_info.get("model", "") selected_endpoint = endpoint or provider_info.get("endpoint", "") else: - provider = Prompt.ask( - "Select the LLM Provider", choices=list(LLM_PROVIDERS.keys()) - ) + import questionary + + provider = questionary.select( + "Select the LLM Provider:", + choices=list(LLM_PROVIDERS.keys()) + ).ask() + + if provider is None: + raise typer.Abort() + provider_info = LLM_PROVIDERS[provider] if provider_info.get("requires_key", True): - api_key = Prompt.ask(f"Enter {provider} API Key", password=True) + api_key = questionary.password(f"Enter {provider} API Key:").ask() + if api_key is None: + raise typer.Abort() else: api_key = "" default_model = provider_info.get("model", "") - selected_model = Prompt.ask("Enter model name", default=model or default_model) + selected_model = questionary.text( + "Enter model name:", default=model or default_model + ).ask() + + if selected_model is None: + raise typer.Abort() if provider == "Ollama" or provider_info.get("endpoint"): default_endpoint = provider_info.get("endpoint", "http://localhost:11434") - selected_endpoint = Prompt.ask( - "Enter endpoint URL", default=endpoint or default_endpoint - ) + selected_endpoint = questionary.text( + "Enter endpoint URL:", default=endpoint or default_endpoint + ).ask() + if selected_endpoint is None: + raise typer.Abort() else: selected_endpoint = endpoint or "" - typer.confirm("Do you want to save the configuration?", abort=True) + confirm = questionary.confirm("Do you want to save the configuration?").ask() + if not confirm: + raise typer.Abort() # Test the provider connection before saving with console.status("[bold green]Testing LLM connection...[/bold green]"): diff --git a/requirements.txt b/requirements.txt index f45032d..a5060cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ langchain-openai langchain-anthropic langchain-ollama pyperclip +questionary diff --git a/tests/test_actions.py b/tests/test_actions.py index 6f6affa..5817d1d 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -22,13 +22,15 @@ def test_copy_to_clipboard_failure(): def test_execute_commands_declined(): - with patch("typer.confirm", return_value=False): + with patch("how.actions.questionary.confirm") as mock_confirm: + mock_confirm.return_value.ask.return_value = False ret = execute_commands(["ls -la"]) assert ret == 0 def test_execute_commands_success(): - with patch("typer.confirm", return_value=True), patch("subprocess.run") as mock_run: + with patch("how.actions.questionary.confirm") as mock_confirm, patch("subprocess.run") as mock_run: + mock_confirm.return_value.ask.return_value = True mock_run.return_value = MagicMock(returncode=0) ret = execute_commands(["echo hello", "echo world"]) assert ret == 0 @@ -36,10 +38,9 @@ def test_execute_commands_success(): def test_execute_commands_failure_stop(): - with patch("typer.confirm", side_effect=[True, False]), patch( - "subprocess.run" - ) as mock_run: + with patch("how.actions.questionary.confirm") as mock_confirm, patch("subprocess.run") as mock_run: # First confirm is to execute, second confirm is whether to continue after failure + mock_confirm.return_value.ask.side_effect = [True, False] mock_run.return_value = MagicMock(returncode=42) ret = execute_commands(["false", "echo unreachable"]) assert ret == 42 @@ -47,20 +48,23 @@ def test_execute_commands_failure_stop(): def test_modify_commands(): - with patch("builtins.input", return_value="echo edited"): + with patch("builtins.input", side_effect=Exception("force fallback")), patch("how.actions.questionary.text") as mock_text: + mock_text.return_value.ask.return_value = "echo edited" res = modify_commands(["echo original"]) assert res == ["echo edited"] def test_interactive_action_menu_abort(): - with patch("rich.prompt.Prompt.ask", return_value="a"): + with patch("how.actions.questionary.select") as mock_select: + mock_select.return_value.ask.return_value = "a" # Should exit cleanly without error interactive_action_menu(["echo test"]) def test_interactive_action_menu_execute(): - with patch("rich.prompt.Prompt.ask", return_value="e"), patch( + with patch("how.actions.questionary.select") as mock_select, patch( "how.actions.execute_commands" ) as mock_exec: + mock_select.return_value.ask.return_value = "e" interactive_action_menu(["echo test"]) mock_exec.assert_called_once_with(["echo test"]) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..dbc1110 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,34 @@ +from unittest.mock import patch +from typer.testing import CliRunner +from how.how import app +from how.core.config import Config + +runner = CliRunner() + +def test_setup_non_interactive(tmp_path): + with patch("how.how.config", Config(config_dir=tmp_path)): + # Test missing provider flag + result = runner.invoke(app, ["setup", "--no-interactive"]) + assert result.exit_code != 0 + assert "Please set the --provider" in result.stdout + + # Test missing api key for provider that needs it + result = runner.invoke(app, ["setup", "--no-interactive", "--provider", "OpenAI"]) + assert result.exit_code != 0 + assert "Please provide --api-key for provider 'OpenAI'" in result.stdout + + # Test valid non-interactive setup (Ollama doesn't require API key) + with patch("how.core.llm.get_llm") as mock_get_llm: + # Mock get_llm to avoid actually calling the LLM + mock_llm = mock_get_llm.return_value + mock_llm.invoke.return_value = "Success" + + result = runner.invoke(app, [ + "setup", + "--no-interactive", + "--provider", "Ollama", + "--endpoint", "http://localhost:11434" + ]) + + assert result.exit_code == 0 + assert "Configuration saved successfully" in result.stdout diff --git a/tests/test_context.py b/tests/test_context.py index 46eef85..6c482db 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -35,14 +35,15 @@ def test_detect_project_no_markers(tmp_path: Path): def test_detect_shell_unix(): - with patch.dict("os.environ", {"SHELL": "/bin/zsh"}): - assert detect_shell() == "zsh" + with patch("platform.system", return_value="Linux"): + with patch.dict("os.environ", {"SHELL": "/bin/zsh"}): + assert detect_shell() == "zsh" - with patch.dict("os.environ", {"SHELL": "/usr/bin/bash"}): - assert detect_shell() == "bash" + with patch.dict("os.environ", {"SHELL": "/usr/bin/bash"}): + assert detect_shell() == "bash" - with patch.dict("os.environ", {"SHELL": "/usr/bin/fish"}): - assert detect_shell() == "fish" + with patch.dict("os.environ", {"SHELL": "/usr/bin/fish"}): + assert detect_shell() == "fish" def test_detect_package_managers():