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
88 changes: 88 additions & 0 deletions strix/interface/config_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Configuration manager for Strix settings."""

import os
from pathlib import Path
from typing import ClassVar

from dotenv import dotenv_values, set_key


class ConfigManager:
"""Manages Strix configuration stored in ~/.strix/.env"""

CONFIG_DIR: ClassVar[Path] = Path.home() / ".strix"
CONFIG_FILE: ClassVar[Path] = CONFIG_DIR / ".env"

REQUIRED_KEYS: ClassVar[list[str]] = ["STRIX_LLM", "LLM_API_KEY"]
OPTIONAL_KEYS: ClassVar[list[str]] = [
"PERPLEXITY_API_KEY",
"LLM_API_BASE",
"OPENAI_API_BASE",
"LITELLM_BASE_URL",
"OLLAMA_API_BASE",
]

@classmethod
def ensure_config_dir(cls) -> None:
"""Ensure the config directory exists."""
cls.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
if not cls.CONFIG_FILE.exists():
cls.CONFIG_FILE.touch()

@classmethod
def load_config(cls) -> dict[str, str]:
"""Load configuration from ~/.strix/.env file."""
cls.ensure_config_dir()

if not cls.CONFIG_FILE.exists():
return {}

config = dotenv_values(cls.CONFIG_FILE)
# Filter out None values
return {k: v for k, v in config.items() if v is not None}

@classmethod
def save_config(cls, config: dict[str, str]) -> None:
"""Save configuration to ~/.strix/.env file."""
cls.ensure_config_dir()

for key, value in config.items():
set_key(cls.CONFIG_FILE, key, value)

@classmethod
def get_value(cls, key: str, default: str = "") -> str:
"""Get a configuration value."""
config = cls.load_config()
return config.get(key, default)

@classmethod
def set_value(cls, key: str, value: str) -> None:
"""Set a configuration value."""
config = cls.load_config()
config[key] = value
cls.save_config(config)

@classmethod
def get_all_config(cls) -> dict[str, str]:
"""Get all configuration values."""
return cls.load_config()

@classmethod
def update_config(cls, updates: dict[str, str]) -> None:
"""Update multiple configuration values."""
config = cls.load_config()
config.update(updates)
cls.save_config(config)

@classmethod
def apply_to_environment(cls) -> None:
"""Apply configuration to current environment.

Only sets values that are not already set in the environment,
allowing environment variables to override config file values.
"""
config = cls.load_config()
for key, value in config.items():
if value and key not in os.environ:
# Only set if not already in environment (env vars take precedence)
os.environ[key] = value
68 changes: 66 additions & 2 deletions strix/interface/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from rich.text import Text

from strix.interface.cli import run_cli
from strix.interface.config_manager import ConfigManager
from strix.interface.menu import show_interactive_menu
from strix.interface.tui import run_tui
from strix.interface.utils import (
assign_workspace_subdirs,
Expand Down Expand Up @@ -270,7 +272,7 @@ def parse_arguments() -> argparse.Namespace:
"-t",
"--target",
type=str,
required=True,
required=False,
action="append",
help="Target to test (URL, repository, local directory path, or domain name). "
"Can be specified multiple times for multi-target scans.",
Expand Down Expand Up @@ -304,6 +306,12 @@ def parse_arguments() -> argparse.Namespace:

args = parser.parse_args()

# If no targets provided, we'll show interactive menu in main()
if not args.target:
args.target = None
args.targets_info = []
return args

args.targets_info = []
for target in args.target:
try:
Expand Down Expand Up @@ -440,12 +448,68 @@ def pull_docker_image() -> None:
console.print()


def main() -> None:
def main() -> None: # noqa: PLR0912, PLR0915
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

# Load configuration from ~/.strix/.env and apply to environment FIRST
# This must happen before any LLM modules are used
# Note: apply_to_environment() only sets values not already in environment,
# allowing env vars to override config file values
ConfigManager.apply_to_environment()

# Re-apply litellm settings after loading config (in case llm.py was already imported)
config = ConfigManager.get_all_config()
if config.get("LLM_API_KEY"):
litellm.api_key = config["LLM_API_KEY"]
if config.get("LLM_API_BASE"):
litellm.api_base = config["LLM_API_BASE"]
elif config.get("OPENAI_API_BASE"):
litellm.api_base = config["OPENAI_API_BASE"]
elif config.get("LITELLM_BASE_URL"):
litellm.api_base = config["LITELLM_BASE_URL"]
elif config.get("OLLAMA_API_BASE"):
litellm.api_base = config["OLLAMA_API_BASE"]

args = parse_arguments()

# If no targets provided, show interactive menu (unless non-interactive mode)
if not args.target:
if args.non_interactive:
console = Console()
error_text = Text()
error_text.append("❌ ", style="bold red")
error_text.append("NO TARGETS PROVIDED", style="bold red")
error_text.append("\n\n", style="white")
error_text.append(
"Non-interactive mode requires at least one target to be specified.\n",
style="white",
)
error_text.append(
"Please provide a target using --target or -t option.\n\n", style="white"
)
error_text.append("Example:\n", style="white")
error_text.append(" strix -n --target https://example.com\n", style="dim white")
error_text.append(" strix -n -t ./local-directory\n", style="dim white")

panel = Panel(
error_text,
title="[bold red]πŸ›‘οΈ STRIX CONFIGURATION ERROR",
title_align="center",
border_style="red",
padding=(1, 2),
)

console.print("\n")
console.print(panel)
console.print()
sys.exit(1)

args = show_interactive_menu()
# Handle cancellation - if user quits menu, exit cleanly
if args is None:
sys.exit(0)

check_docker_installed()
pull_docker_image()

Expand Down
Loading