perf(cli): fast startup — lazy heavy imports + onedir standalone build

This commit is contained in:
Ahmed Allam 2026-07-27 23:06:06 +00:00
parent f968f8e5a7
commit e3be6253f5
13 changed files with 206 additions and 83 deletions

View file

@ -45,15 +45,16 @@ jobs:
uv sync --frozen
uv run pyinstaller strix.spec --noconfirm
# Onedir build: dist/strix/ is an app directory (strix + _internal/).
if [[ "${{ runner.os }}" == "Windows" ]]; then
dist/strix.exe --version
dist/strix/strix.exe --version
else
dist/strix --version
dist/strix/strix --version
fi
if [[ "${{ matrix.target }}" == "linux-arm64" ]]; then
file dist/strix
file dist/strix | grep -q "ARM aarch64" || {
file dist/strix/strix
file dist/strix/strix | grep -q "ARM aarch64" || {
echo "::error::linux-arm64 artifact is not an ARM aarch64 binary"
exit 1
}
@ -62,13 +63,15 @@ jobs:
VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
mkdir -p dist/release
cp -R dist/strix "dist/release/strix-${VERSION}-${{ matrix.target }}"
if [[ "${{ runner.os }}" == "Windows" ]]; then
cp dist/strix.exe "dist/release/strix-${VERSION}-${{ matrix.target }}.exe"
(cd dist/release && 7z a "strix-${VERSION}-${{ matrix.target }}.zip" "strix-${VERSION}-${{ matrix.target }}.exe")
(cd dist/release && 7z a "strix-${VERSION}-${{ matrix.target }}.zip" "strix-${VERSION}-${{ matrix.target }}")
rm -rf "dist/release/strix-${VERSION}-${{ matrix.target }}"
else
cp dist/strix "dist/release/strix-${VERSION}-${{ matrix.target }}"
chmod +x "dist/release/strix-${VERSION}-${{ matrix.target }}"
chmod +x "dist/release/strix-${VERSION}-${{ matrix.target }}/strix"
tar -C dist/release -czvf "dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz" "strix-${VERSION}-${{ matrix.target }}"
rm -rf "dist/release/strix-${VERSION}-${{ matrix.target }}"
fi
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2

View file

@ -54,45 +54,48 @@ uv run pyinstaller strix.spec --noconfirm
RELEASE_DIR="dist/release"
mkdir -p "$RELEASE_DIR"
BINARY_NAME="strix-${VERSION}-${OS_NAME}-${ARCH_NAME}"
# Onedir build: dist/strix/ is an app directory (strix + _internal/).
BUNDLE_NAME="strix-${VERSION}-${OS_NAME}-${ARCH_NAME}"
if [ "$OS_NAME" = "windows" ]; then
EXE_NAME="strix.exe"
else
EXE_NAME="strix"
fi
if [ ! -f "dist/strix/$EXE_NAME" ]; then
echo -e "${RED}Build failed: Binary not found${NC}"
exit 1
fi
cp -R "dist/strix" "$RELEASE_DIR/$BUNDLE_NAME"
chmod +x "$RELEASE_DIR/$BUNDLE_NAME/$EXE_NAME"
if [ "$OS_NAME" = "windows" ]; then
if [ ! -f "dist/strix.exe" ]; then
echo -e "${RED}Build failed: Binary not found${NC}"
exit 1
fi
BINARY_NAME="${BINARY_NAME}.exe"
cp "dist/strix.exe" "$RELEASE_DIR/$BINARY_NAME"
echo -e "\n${BLUE}Creating zip...${NC}"
ARCHIVE_NAME="${BINARY_NAME%.exe}.zip"
ARCHIVE_NAME="${BUNDLE_NAME}.zip"
if command -v 7z &> /dev/null; then
7z a "$RELEASE_DIR/$ARCHIVE_NAME" "$RELEASE_DIR/$BINARY_NAME"
(cd "$RELEASE_DIR" && 7z a "$ARCHIVE_NAME" "$BUNDLE_NAME")
else
powershell -Command "Compress-Archive -Path '$RELEASE_DIR/$BINARY_NAME' -DestinationPath '$RELEASE_DIR/$ARCHIVE_NAME'"
powershell -Command "Compress-Archive -Path '$RELEASE_DIR/$BUNDLE_NAME' -DestinationPath '$RELEASE_DIR/$ARCHIVE_NAME'"
fi
echo -e "${GREEN}Created:${NC} $RELEASE_DIR/$ARCHIVE_NAME"
else
if [ ! -f "dist/strix" ]; then
echo -e "${RED}Build failed: Binary not found${NC}"
exit 1
fi
cp "dist/strix" "$RELEASE_DIR/$BINARY_NAME"
chmod +x "$RELEASE_DIR/$BINARY_NAME"
echo -e "\n${BLUE}Creating tarball...${NC}"
ARCHIVE_NAME="${BINARY_NAME}.tar.gz"
tar -czvf "$RELEASE_DIR/$ARCHIVE_NAME" -C "$RELEASE_DIR" "$BINARY_NAME"
ARCHIVE_NAME="${BUNDLE_NAME}.tar.gz"
tar -czvf "$RELEASE_DIR/$ARCHIVE_NAME" -C "$RELEASE_DIR" "$BUNDLE_NAME"
echo -e "${GREEN}Created:${NC} $RELEASE_DIR/$ARCHIVE_NAME"
fi
echo -e "\n${GREEN}Build successful!${NC}"
echo "================================"
echo -e "${YELLOW}Binary:${NC} $RELEASE_DIR/$BINARY_NAME"
echo -e "${YELLOW}App directory:${NC} $RELEASE_DIR/$BUNDLE_NAME"
SIZE=$(ls -lh "$RELEASE_DIR/$BINARY_NAME" | awk '{print $5}')
SIZE=$(du -sh "$RELEASE_DIR/$BUNDLE_NAME" | awk '{print $1}')
echo -e "${YELLOW}Size:${NC} $SIZE"
echo -e "\n${BLUE}Testing binary...${NC}"
"$RELEASE_DIR/$BINARY_NAME" --help > /dev/null 2>&1 && echo -e "${GREEN}Binary test passed!${NC}" || echo -e "${RED}Binary test failed${NC}"
"$RELEASE_DIR/$BUNDLE_NAME/$EXE_NAME" --help > /dev/null 2>&1 && echo -e "${GREEN}Binary test passed!${NC}" || echo -e "${RED}Binary test failed${NC}"
echo -e "\n${GREEN}Done!${NC}"

View file

@ -159,12 +159,28 @@ download_and_install() {
fi
echo -e "${MUTED}Extracting...${NC}"
local extracted="strix-${specific_version}-${target}"
if [ "$os" = "windows" ]; then
unzip -q "$filename"
mv "strix-${specific_version}-${target}.exe" "$INSTALL_DIR/strix.exe"
if [ -d "$extracted" ]; then
# Onedir app bundle (strix.exe + _internal/).
rm -rf "$INSTALL_DIR/_internal" "$INSTALL_DIR/strix.exe"
cp -R "$extracted/." "$INSTALL_DIR/"
else
# Legacy single-file binary.
mv "${extracted}.exe" "$INSTALL_DIR/strix.exe"
fi
else
tar -xzf "$filename"
mv "strix-${specific_version}-${target}" "$INSTALL_DIR/strix"
if [ -d "$extracted" ]; then
# Onedir app bundle (strix + _internal/).
rm -rf "$INSTALL_DIR/_internal" "$INSTALL_DIR/strix"
cp -R "$extracted/." "$INSTALL_DIR/"
else
# Legacy single-file binary.
rm -rf "$INSTALL_DIR/strix"
mv "$extracted" "$INSTALL_DIR/strix"
fi
chmod 755 "$INSTALL_DIR/strix"
fi

View file

@ -276,19 +276,19 @@ a = Analysis(
pyz = PYZ(a.pure)
# Onedir build: no self-extraction at startup, so the CLI starts fast on
# every run (onefile unpacks the full bundle to a temp dir each invocation).
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
exclude_binaries=True,
name='strix',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
@ -296,3 +296,13 @@ exe = EXE(
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name='strix',
)

3
strix/core/constants.py Normal file
View file

@ -0,0 +1,3 @@
"""Lightweight constants importable without pulling in the agent runtime."""
DEFAULT_MAX_TURNS = 500

View file

@ -24,9 +24,6 @@ if TYPE_CHECKING:
from strix.config.settings import ReasoningEffort
DEFAULT_MAX_TURNS = 500
def _accepts_required_tool_choice(model_name: str | None) -> bool:
name = (model_name or "").strip().lower()
for prefix in ("litellm/", "any-llm/"):

View file

@ -24,6 +24,7 @@ from strix.config.models import (
uses_chat_completions_tool_schema,
)
from strix.core.agents import AgentCoordinator
from strix.core.constants import DEFAULT_MAX_TURNS
from strix.core.execution import (
respawn_subagents,
run_agent_loop,
@ -33,7 +34,6 @@ from strix.core.execution import (
)
from strix.core.hooks import BudgetExceededError, ReportUsageHooks, recomputed_budget_flags
from strix.core.inputs import (
DEFAULT_MAX_TURNS,
build_root_task,
build_scope_context,
make_model_settings,

View file

@ -1,4 +1,16 @@
from .main import main
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .main import main
__all__ = ["main"]
def __getattr__(name: str) -> object:
if name == "main":
from .main import main
return main
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View file

@ -13,7 +13,7 @@ from rich.panel import Panel
from rich.text import Text
from strix.config import load_settings
from strix.core.inputs import DEFAULT_MAX_TURNS
from strix.core.constants import DEFAULT_MAX_TURNS
from strix.core.runner import run_strix_scan
from strix.report.state import ReportState, set_global_report_state
from strix.runtime import session_manager

View file

@ -11,9 +11,6 @@ import sys
from datetime import UTC, datetime
from pathlib import Path
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from docker.errors import DockerException
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
@ -24,17 +21,8 @@ from strix.config import (
load_settings,
persist_current,
)
from strix.config.models import (
RECOMMENDED_MODEL_NAMES,
StrixProvider,
configure_sdk_model_defaults,
is_known_openai_bare_model,
is_recommended_or_frontier_model,
)
from strix.core.inputs import DEFAULT_MAX_TURNS
from strix.core.constants import DEFAULT_MAX_TURNS
from strix.core.paths import run_dir_for, runtime_state_dir
from strix.interface.cli import run_cli
from strix.interface.tui import run_tui
from strix.interface.update_check import (
is_binary_install,
notify_update,
@ -61,8 +49,6 @@ from strix.interface.utils import (
rewrite_localhost_targets,
validate_config_file,
)
from strix.report.state import get_global_report_state
from strix.report.writer import read_run_record, write_run_record
from strix.telemetry import posthog, scarf
from strix.telemetry.logging import configure_dependency_logging
@ -313,6 +299,17 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
console = Console()
logger.info("Warming up LLM connection")
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from strix.config.models import (
RECOMMENDED_MODEL_NAMES,
StrixProvider,
configure_sdk_model_defaults,
is_known_openai_bare_model,
is_recommended_or_frontier_model,
)
raw_model = ""
try:
settings = load_settings()
@ -776,6 +773,8 @@ Examples:
def _persist_run_record(args: argparse.Namespace) -> None:
from strix.report.writer import write_run_record
run_dir = run_dir_for(args.run_name)
run_dir.mkdir(parents=True, exist_ok=True)
run_record = {
@ -799,6 +798,8 @@ def _persist_run_record(args: argparse.Namespace) -> None:
def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
"""Populate ``args.targets_info`` and friends from a prior run's run.json."""
from strix.report.writer import read_run_record
run_dir = run_dir_for(args.resume)
state_path = run_dir / "run.json"
if not state_path.exists():
@ -843,6 +844,8 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
from strix.report.state import get_global_report_state
console = Console()
report_state = get_global_report_state()
@ -922,6 +925,8 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
def pull_docker_image() -> None:
from docker.errors import DockerException
console = Console()
client = check_docker_connection()
@ -1071,8 +1076,12 @@ def main() -> None:
exit_reason = "user_exit"
try:
if args.non_interactive:
from strix.interface.cli import run_cli
asyncio.run(run_cli(args))
else:
from strix.interface.tui import run_tui
asyncio.run(run_tui(args))
except KeyboardInterrupt:
exit_reason = "interrupted"
@ -1082,6 +1091,8 @@ def main() -> None:
scarf.error("unhandled_exception")
raise
finally:
from strix.report.state import get_global_report_state
report_state = get_global_report_state()
if report_state:
status = {"interrupted": "interrupted", "error": "failed"}.get(
@ -1097,6 +1108,8 @@ def main() -> None:
display_completion_message(args, results_path)
if args.non_interactive:
from strix.report.state import get_global_report_state
report_state = get_global_report_state()
if report_state and report_state.vulnerability_reports:
sys.exit(2)

View file

@ -33,8 +33,8 @@ from textual.widgets.tree import TreeNode
from strix.config import load_settings
from strix.config.models import is_recommended_or_frontier_model
from strix.core.constants import DEFAULT_MAX_TURNS
from strix.core.hooks import BudgetExceededError
from strix.core.inputs import DEFAULT_MAX_TURNS
from strix.core.runner import run_strix_scan
from strix.interface.tui.live_view import TuiLiveView
from strix.interface.tui.messages import send_user_message_to_agent

View file

@ -316,35 +316,99 @@ def _download_and_replace(version: str, target: str, console: Console) -> bool:
if is_windows:
with zipfile.ZipFile(archive_path) as zf:
zf.extract(binary_name, tmp_dir)
zf.extractall(tmp_dir) # noqa: S202 # nosec B202 checksum-verified above
else:
with tarfile.open(archive_path, "r:gz") as tf:
tf.extract(binary_name, tmp_dir, filter="data")
tf.extractall(tmp_dir, filter="data") # nosec B202
bundle_dir = tmp_dir / f"strix-{version}-{target}"
if bundle_dir.is_dir():
_swap_app_bundle(bundle_dir, current_exe, is_windows=is_windows)
return True
new_binary = tmp_dir / binary_name
new_binary.chmod(new_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
staged = current_exe.with_name(current_exe.name + ".new")
try:
shutil.copy2(new_binary, staged)
if is_windows:
# Windows can't replace a running executable in place; move it aside first.
old = current_exe.with_name(current_exe.name + ".old")
old.unlink(missing_ok=True)
current_exe.rename(old)
try:
staged.replace(current_exe)
except Exception:
old.rename(current_exe)
raise
else:
staged.replace(current_exe)
except Exception:
staged.unlink(missing_ok=True)
raise
if not new_binary.is_file():
raise RuntimeError(f"unexpected archive layout in {filename}")
_swap_single_binary(new_binary, current_exe, is_windows=is_windows)
return True
def _swap_single_binary(new_binary: Path, current_exe: Path, *, is_windows: bool) -> None:
"""Replace a legacy single-file binary in place."""
new_binary.chmod(new_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
staged = current_exe.with_name(current_exe.name + ".new")
try:
shutil.copy2(new_binary, staged)
if is_windows:
# Windows can't replace a running executable in place; move it aside first.
old = current_exe.with_name(current_exe.name + ".old")
old.unlink(missing_ok=True)
current_exe.rename(old)
try:
staged.replace(current_exe)
except Exception:
old.rename(current_exe)
raise
else:
staged.replace(current_exe)
except Exception:
staged.unlink(missing_ok=True)
raise
def _swap_app_bundle(bundle_dir: Path, current_exe: Path, *, is_windows: bool) -> None:
"""Swap an onedir app bundle (exe + ``_internal/``) into place."""
exe_name = "strix.exe" if is_windows else "strix"
new_exe = bundle_dir / exe_name
new_internal = bundle_dir / "_internal"
if not new_exe.is_file() or not new_internal.is_dir():
raise RuntimeError("unexpected app bundle layout (missing strix or _internal)")
new_exe.chmod(new_exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
app_dir = current_exe.parent
internal = app_dir / "_internal"
old_internal = app_dir / "_internal.old"
staged_internal = app_dir / "_internal.new"
staged_exe = current_exe.with_name(current_exe.name + ".new")
old_exe = current_exe.with_name(current_exe.name + ".old")
shutil.rmtree(staged_internal, ignore_errors=True)
shutil.rmtree(old_internal, ignore_errors=True)
staged_exe.unlink(missing_ok=True)
old_exe.unlink(missing_ok=True)
shutil.copytree(new_internal, staged_internal)
shutil.copy2(new_exe, staged_exe)
internal_moved = False
try:
internal.rename(old_internal)
internal_moved = True
staged_internal.rename(internal)
if is_windows:
# Windows can't replace a running executable in place; move it aside first.
current_exe.rename(old_exe)
try:
staged_exe.replace(current_exe)
except Exception:
old_exe.rename(current_exe)
raise
else:
staged_exe.replace(current_exe)
except Exception:
if internal_moved and not internal.exists() and old_internal.exists():
old_internal.rename(internal)
shutil.rmtree(staged_internal, ignore_errors=True)
staged_exe.unlink(missing_ok=True)
raise
# Best effort: on Windows loaded DLLs/exe may resist deletion until restart.
shutil.rmtree(old_internal, ignore_errors=True)
if not is_windows:
old_exe.unlink(missing_ok=True)
def self_update(console: Console | None = None, version: str | None = None) -> bool:
"""Replace the running standalone binary with the latest release.

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import contextlib
import logging
import os
import sys
import warnings
from contextvars import ContextVar
from pathlib import Path # noqa: TC003 used at runtime by ``setup_scan_logging``
@ -78,11 +79,12 @@ class _StdoutQuietFilter(logging.Filter):
def configure_dependency_logging() -> None:
"""Quiet dependency logging/warnings that obscure Strix scan logs."""
with contextlib.suppress(Exception):
import litellm
litellm_logging = litellm._logging
litellm_logging._disable_debugging() # type: ignore[no-untyped-call]
# Only quiet litellm when it is already loaded: importing it here would
# pull the whole package in (~1s+) even for paths that never use it.
litellm = sys.modules.get("litellm")
if litellm is not None:
with contextlib.suppress(Exception):
litellm._logging._disable_debugging()
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
logging.getLogger("asyncio").propagate = False