Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 21 additions & 16 deletions how/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
36 changes: 27 additions & 9 deletions how/how.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]"):
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ langchain-openai
langchain-anthropic
langchain-ollama
pyperclip
questionary
20 changes: 12 additions & 8 deletions tests/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,45 +22,49 @@ 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
assert mock_run.call_count == 2


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
assert mock_run.call_count == 1


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"])
34 changes: 34 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 7 additions & 6 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down