Skip to content
Merged
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
23 changes: 21 additions & 2 deletions docs/docs/getting-started/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ hackagent init
```

This will guide you through:
1. **Set verbosity level** — Control logging detail (0=ERROR to 3=DEBUG)
2. **Save configuration** — Stored in `~/.config/hackagent/config.json`
1. **Choose mode** — Local (no API key) or Remote (cloud sync)
2. **Configure remote credentials (optional)** — API key when Remote mode is enabled
3. **Set verbosity level** — Control logging detail (0=ERROR to 3=DEBUG)
4. **Save configuration** — Stored in `~/.config/hackagent/config.json`

:::info Local and Remote
By default, HackAgent runs locally and stores results in SQLite (`~/.local/share/hackagent/hackagent.db`).
Expand All @@ -56,7 +58,24 @@ export HACKAGENT_API_KEY="your_api_key"
export HACKAGENT_BASE_URL="https://api.hackagent.dev"
```

You can also configure these values interactively with:

```bash
hackagent init
```

Or set them later without re-running the wizard:

```bash
hackagent config set --api-key YOUR_KEY
hackagent config set --base-url https://api.hackagent.dev
```

In remote mode, results are synchronized to the HackAgent platform while local mode remains available.

`hackagent web` follows the selected mode:
- Local mode: launches the local dashboard server (`http://127.0.0.1:7860` by default)
- Remote mode: opens `https://app.hackagent.dev`
:::

---
Expand Down
69 changes: 31 additions & 38 deletions hackagent/cli/commands/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
"""
``hackagent web`` — web dashboard command.

Starts a NiceGUI server that reads from the local SQLite backend
(or the remote backend when an API key is configured) and serves the
In local mode, starts a NiceGUI server backed by local SQLite and serves the
dashboard at http://<host>:<port>/.

In remote mode (API key configured), opens the cloud dashboard at
https://app.hackagent.dev.
"""

import click
import httpx
from rich.console import Console

console = Console()
Expand Down Expand Up @@ -45,9 +46,8 @@
def web(ctx, host, port, db_path, no_browser):
"""🌐 Launch the web dashboard.

Starts a local web server that serves a full-featured security testing
dashboard. Works in both offline mode (SQLite) and online mode (remote
API with an API key).
Local mode: starts a local web server that serves the dashboard.
Remote mode: opens the HackAgent cloud dashboard.

\b
Examples:
Expand All @@ -56,6 +56,27 @@ def web(ctx, host, port, db_path, no_browser):
hackagent web --host 0.0.0.0 # expose on all interfaces
hackagent web --no-browser # skip opening a browser tab
"""
from hackagent.cli.config import CLIConfig

cli_config: CLIConfig = ctx.obj["config"]

# In remote mode, open the cloud dashboard directly instead of serving local UI.
if cli_config.api_key:
cloud_url = "https://app.hackagent.dev"
console.print(
"[dim]Remote mode detected: using HackAgent cloud dashboard.[/dim]"
)
console.print(f"[cyan]{cloud_url}[/cyan]")
if not no_browser:
import webbrowser

opened = webbrowser.open(cloud_url)
if not opened:
console.print(
"[yellow]⚠️ Could not auto-open browser. Open the URL above manually.[/yellow]"
)
return

try:
from flask import Flask # noqa: F401
except ImportError:
Expand All @@ -69,40 +90,12 @@ def web(ctx, host, port, db_path, no_browser):
ctx.exit(1)
return

from hackagent.cli.config import CLIConfig
from hackagent.server.dashboard import create_app

cli_config: CLIConfig = ctx.obj["config"]

# ── Select backend ────────────────────────────────────────────────────────
backend = None
if cli_config.api_key:
try:
from hackagent.server.client import AuthenticatedClient
from hackagent.server.storage.remote import RemoteBackend

client = AuthenticatedClient(
base_url=cli_config.base_url,
token=cli_config.api_key,
# Never disable HTTP timeouts in web mode: a stuck remote call
# would otherwise keep the dashboard loading forever.
timeout=httpx.Timeout(15.0, connect=5.0, read=15.0, write=15.0),
)
candidate_backend = RemoteBackend(client=client)
# Preflight check to fail fast on invalid/unreachable remote config.
candidate_backend.get_context()
backend = candidate_backend
console.print("[dim]Using remote backend.[/dim]")
except Exception as exc:
console.print(
f"[yellow]⚠️ Could not connect to remote backend ({exc}). "
"Falling back to local SQLite.[/yellow]"
)

if backend is None:
from hackagent.server.storage.local import LocalBackend

backend = LocalBackend(db_path=db_path)
from hackagent.server.storage.local import LocalBackend

backend = LocalBackend(db_path=db_path)

# ── Create app ────────────────────────────────────────────────────────────
app = create_app(backend=backend)
Expand All @@ -112,7 +105,7 @@ def web(ctx, host, port, db_path, no_browser):
console.print()
console.print("[bold]🌐 HackAgent Dashboard[/bold]")
console.print(f" [cyan]→ {url}[/cyan]")
mode_label = "remote" if backend.__class__.__name__ == "RemoteBackend" else "local"
mode_label = "local"
console.print(f" Mode : [cyan]{mode_label}[/cyan]")
if mode_label == "local":
resolved_db = db_path or "~/.local/share/hackagent/hackagent.db"
Expand Down
49 changes: 45 additions & 4 deletions hackagent/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,41 @@ def init(ctx):
# Reload config from file to get the latest saved values
cli_config._load_default_config()

# Mode and API key setup
console.print("\n[cyan]☁️ Mode Configuration[/cyan]")
console.print("[green]Local mode (default):[/green] no API key required")
console.print(
"[green]Remote mode:[/green] requires HackAgent API key for cloud sync"
)

use_remote = click.confirm(
"Enable remote mode (cloud sync)?",
default=False,
)

if use_remote:
if cli_config.api_key:
console.print(
"[dim]Press Enter to keep your current API key from config/environment.[/dim]"
)

api_key_input = click.prompt(
"HackAgent API key",
default=cli_config.api_key or "",
hide_input=True,
show_default=False,
).strip()

if api_key_input:
cli_config.api_key = api_key_input
else:
console.print(
"[yellow]⚠️ No API key provided. Falling back to local mode.[/yellow]"
)
cli_config.api_key = None
else:
cli_config.api_key = None

# Verbosity level setup
console.print("\n[cyan]🔊 Verbosity Level Configuration[/cyan]")
console.print("0 = ERROR (only errors)")
Expand All @@ -290,10 +325,16 @@ def init(ctx):
cli_config.save()
console.print("\n[bold green]✅ Configuration saved[/bold green]")

console.print(
"[bold green]✅ Setup complete![/bold green] "
"[dim](Local mode: results stored in ~/.local/share/hackagent/hackagent.db)[/dim]"
)
if cli_config.api_key:
console.print(
"[bold green]✅ Setup complete![/bold green] "
"[dim](Remote mode enabled: runs can sync to the HackAgent platform)[/dim]"
)
else:
console.print(
"[bold green]✅ Setup complete![/bold green] "
"[dim](Local mode: results stored in ~/.local/share/hackagent/hackagent.db)[/dim]"
)
if cli_config.should_show_info():
console.print("\n[bold cyan]💡 Next steps:[/bold cyan]")
console.print(" [green]hackagent eval advprefix --help[/green]")
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/cli/test_cli_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,5 +182,79 @@ def test_no_command_launches_tui(self, mock_tui, mock_config_class):
mock_tui.assert_called_once()


class TestCLIInit(unittest.TestCase):
"""Test the init setup wizard."""

@patch("hackagent.utils.display_hackagent_splash")
@patch("hackagent.cli.main.click.prompt")
@patch("hackagent.cli.main.click.confirm")
@patch("hackagent.cli.main.CLIConfig")
def test_init_configures_remote_mode(
self,
mock_config_class,
mock_confirm,
mock_prompt,
mock_splash,
):
"""Test init captures API key when remote mode is enabled."""
mock_config = MagicMock()
mock_config.api_key = None
mock_config.base_url = "https://api.hackagent.dev"
mock_config.verbose = 1
mock_config.should_show_info.return_value = False
mock_config.default_config_path = MagicMock()
mock_config.default_config_path.exists.return_value = False
mock_config_class.return_value = mock_config

# Prompt order in init:
# 1) API key, 2) verbosity level
mock_confirm.return_value = True
mock_prompt.side_effect = ["test-api-key", 2]

runner = CliRunner()
result = runner.invoke(cli, ["init"])

self.assertEqual(result.exit_code, 0)
self.assertEqual(mock_config.api_key, "test-api-key")
self.assertEqual(mock_config.base_url, "https://api.hackagent.dev")
self.assertEqual(mock_config.verbose, 2)
mock_config.save.assert_called_once()
mock_splash.assert_called_once()

@patch("hackagent.utils.display_hackagent_splash")
@patch("hackagent.cli.main.click.prompt")
@patch("hackagent.cli.main.click.confirm")
@patch("hackagent.cli.main.CLIConfig")
def test_init_local_mode_clears_api_key(
self,
mock_config_class,
mock_confirm,
mock_prompt,
mock_splash,
):
"""Test init local mode clears any existing API key from config."""
mock_config = MagicMock()
mock_config.api_key = "existing-key"
mock_config.base_url = "https://api.hackagent.dev"
mock_config.verbose = 1
mock_config.should_show_info.return_value = False
mock_config.default_config_path = MagicMock()
mock_config.default_config_path.exists.return_value = False
mock_config_class.return_value = mock_config

# Local mode confirmation + verbosity prompt.
mock_confirm.return_value = False
mock_prompt.return_value = 1

runner = CliRunner()
result = runner.invoke(cli, ["init"])

self.assertEqual(result.exit_code, 0)
self.assertIsNone(mock_config.api_key)
self.assertEqual(mock_config.verbose, 1)
mock_config.save.assert_called_once()
mock_splash.assert_called_once()


if __name__ == "__main__":
unittest.main()
Loading
Loading