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
117 changes: 112 additions & 5 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,119 @@ jobs:
run: uv publish

- name: Create GitHub Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
release_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
body_path: CHANGELOG.md
draft: false
prerelease: false
prerelease: false

build-binaries:
name: Build binary (${{ matrix.target }})
# Wait for the release to exist so the asset uploads attach to it instead of
# racing the publish job to create it (which would drop the changelog body).
needs: publish
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: linux-x86_64
archive: tar.gz
- os: macos-latest
target: macos-arm64
archive: tar.gz
- os: macos-13
target: macos-x86_64
archive: tar.gz
- os: windows-latest
target: windows-x86_64
archive: zip
runs-on: ${{ matrix.os }}
permissions:
contents: write
steps:
- uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.11'

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: ${{ env.UV_VERSION }}
enable-cache: true

- name: Install build dependencies
run: uv sync --no-default-groups --group binaries

- name: Build binary
env:
HACKAGENT_BUILD_VERSION: ${{ github.ref_name }}
run: uv run --no-sync pyinstaller --noconfirm packaging/hackagent.spec

- name: Smoke test binary (Unix)
if: runner.os != 'Windows'
run: |
./dist/hackagent/hackagent --version
./dist/hackagent/hackagent version
./dist/hackagent/hackagent scan --help

- name: Smoke test binary (Windows)
if: runner.os == 'Windows'
run: |
.\dist\hackagent\hackagent.exe --version
.\dist\hackagent\hackagent.exe version
.\dist\hackagent\hackagent.exe scan --help

- name: Smoke test TUI launch (Unix)
if: runner.os != 'Windows'
env:
TERM: xterm-256color
run: |
# No subcommand launches the Textual TUI. Start it, let it settle,
# then kill it: still running after the wait means it came up cleanly.
./dist/hackagent/hackagent < /dev/null > tui.log 2>&1 &
pid=$!
sleep 20
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
cat tui.log
else
set +e
wait "$pid"
status=$?
set -e
cat tui.log
if [ "$status" -ne 0 ]; then
echo "TUI failed to launch (exit $status)"
exit 1
fi
fi

- name: Package archive (Unix)
if: runner.os != 'Windows'
run: |
VERSION="${GITHUB_REF_NAME#v}"
NAME="hackagent-${VERSION}-${{ matrix.target }}"
mv dist/hackagent "dist/${NAME}"
tar -czf "${NAME}.tar.gz" -C dist "${NAME}"

- name: Package archive (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$version = "${env:GITHUB_REF_NAME}" -replace '^v', ''
$name = "hackagent-$version-${{ matrix.target }}"
Move-Item dist/hackagent "dist/$name"
Compress-Archive -Path "dist/$name" -DestinationPath "$name.zip"

- name: Attach archive to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: hackagent-*.${{ matrix.archive }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ MANIFEST
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# The release build spec is checked in, unlike generated ones.
!packaging/hackagent.spec

# Installer logs
pip-log.txt
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,27 @@ pip install hackagent

No API key required: HackAgent works locally out of the box.

### Standalone binary (no Python required)

Every [GitHub Release](https://github.com/AISecurityLab/hackagent/releases) ships a
self-contained archive per platform:

| Platform | Asset |
|----------|-------|
| Linux x86_64 | `hackagent-<version>-linux-x86_64.tar.gz` |
| macOS arm64 | `hackagent-<version>-macos-arm64.tar.gz` |
| macOS x86_64 | `hackagent-<version>-macos-x86_64.tar.gz` |
| Windows x86_64 | `hackagent-<version>-windows-x86_64.zip` |

Extract the archive and run the `hackagent` launcher inside it — no Python, `pip`
or `uv` install needed. Keep the extracted folder intact; the launcher loads the
libraries next to it.

> **Note:** the `WEB` provider drives a real browser through Playwright, whose
> browser binaries cannot be embedded in the archive (or in the PyPI package).
> Before using `hackagent web`-based targets, install them once with
> `playwright install`.


Questions? Join [community discussions](https://github.com/AISecurityLab/hackagent/discussions) or email ais@ai4i.it.

Expand Down
21 changes: 21 additions & 0 deletions hackagent/_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copyright 2026 - AI4I. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Version lookup that also works from a frozen (PyInstaller) binary."""

import os
from importlib.metadata import PackageNotFoundError, version as _distribution_version

UNKNOWN_VERSION = "unknown"


def get_version() -> str:
"""Return the installed ``hackagent`` version.

Frozen builds may ship without distribution metadata, so fall back to the
``HACKAGENT_BUILD_VERSION`` value baked in at packaging time.
"""
try:
return _distribution_version("hackagent")
except PackageNotFoundError:
return os.environ.get("HACKAGENT_BUILD_VERSION") or UNKNOWN_VERSION
4 changes: 2 additions & 2 deletions hackagent/cli/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ def _launch_tui_default(ctx):
app = HackAgentTUI(cli_config)
app.run()

except ImportError:
console.print("[bold red]❌ TUI dependencies not installed[/bold red]")
except ImportError as e:
console.print(f"[bold red]❌ TUI dependencies not installed: {e}[/bold red]")
console.print("\n[cyan]💡 Install with:[/cyan]")
console.print(" uv add textual")
console.print(" # or")
Expand Down
6 changes: 3 additions & 3 deletions hackagent/cli/help_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@

"""Rich-formatted ``hackagent --help`` page for the top-level CLI group."""

import importlib.metadata

import click
from rich.console import Console
from rich.panel import Panel

from hackagent._version import get_version

console = Console()


Expand All @@ -22,7 +22,7 @@ def _render_rich_help(ctx: click.Context) -> None:
from hackagent.utils import HACKAGENT_BANNER

c = Console()
version = importlib.metadata.version("hackagent")
version = get_version()

# ── Logo ──────────────────────────────────────────────────────────────────
c.print(
Expand Down
10 changes: 3 additions & 7 deletions hackagent/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
Main command-line interface for HackAgent security testing toolkit.
"""

import importlib.metadata
import importlib.util
import os

import click
from rich.console import Console
from rich.traceback import install

from hackagent._version import get_version
from hackagent.cli.commands import (
attack,
claude as claude_cmd,
Expand Down Expand Up @@ -68,9 +68,7 @@
help="HackAgent API base URL",
)
@click.option("--verbose", "-v", count=True, help="Increase verbosity (-v, -vv, -vvv)")
@click.version_option(
version=importlib.metadata.version("hackagent"), prog_name="hackagent"
)
@click.version_option(version=get_version(), prog_name="hackagent")
@click.pass_context
def cli(ctx, config_file, api_key, base_url, verbose):
ctx.ensure_object(dict)
Expand Down Expand Up @@ -264,9 +262,7 @@ def version(ctx):

display_hackagent_splash()

console.print(
f"[bold cyan]HackAgent CLI v{importlib.metadata.version('hackagent')}[/bold cyan]"
)
console.print(f"[bold cyan]HackAgent CLI v{get_version()}[/bold cyan]")
console.print(
"[bold green]Python Security Testing Toolkit for AI Agents[/bold green]"
)
Expand Down
5 changes: 2 additions & 3 deletions hackagent/cli/tui/views/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@
Manage HackAgent configuration settings.
"""

import importlib.metadata

from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Button, Static

from hackagent._version import get_version
from hackagent.cli.config import CLIConfig


Expand Down Expand Up @@ -75,7 +74,7 @@ def compose(self) -> ComposeResult:

yield Static(
f"""[dim]Python Version:[/dim] {self._get_python_version()}
[dim]CLI Version:[/dim] {importlib.metadata.version("hackagent")}
[dim]CLI Version:[/dim] {get_version()}
[dim]Dependencies:[/dim] {self._check_dependencies()}
[dim]Local DB:[/dim] ~/.local/share/hackagent/hackagent.db""",
classes="info-box",
Expand Down
12 changes: 12 additions & 0 deletions packaging/entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Copyright 2026 - AI4I. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Entry point for the frozen ``hackagent`` binary."""

import multiprocessing

from hackagent.cli.main import cli

if __name__ == "__main__":
multiprocessing.freeze_support()
cli()
87 changes: 87 additions & 0 deletions packaging/hackagent.spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Copyright 2026 - AI4I. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""PyInstaller spec for the standalone ``hackagent`` binary.

Build with::

uv run pyinstaller packaging/hackagent.spec

Produces a ``dist/hackagent/`` directory (onedir) containing a ``hackagent``
launcher. Onedir is used instead of onefile because onefile unpacks the whole
bundle to a temp directory on every launch, which is slow for an app this size.
"""

import os
from pathlib import Path

from PyInstaller.utils.hooks import (
collect_data_files,
collect_submodules,
copy_metadata,
)

PROJECT_ROOT = Path(SPECPATH).resolve().parent
BUILD_DIR = Path(workpath).resolve()

# ``importlib.metadata.version("hackagent")`` is used for ``--version`` and the
# ``version`` command. Ship the distribution metadata so it keeps working, and
# bake the version into a runtime hook as a belt-and-braces fallback.
datas = copy_metadata("hackagent")

# Textual and NiceGUI serve non-Python assets (CSS, static web files) from their
# package data, which the default import hooks do not collect.
for package in ("textual", "nicegui"):
datas += collect_data_files(package)

# First-party non-Python assets (dataset taxonomies, bundled examples, docs).
datas += collect_data_files("hackagent", include_py_files=False)

hiddenimports = ["faiss"]

# Textual resolves widgets lazily through ``textual.widgets.__getattr__`` and the
# TUI views are imported by name, so static analysis never sees either of them.
hiddenimports += collect_submodules("textual")
hiddenimports += collect_submodules("hackagent.cli.tui")

_build_version = os.environ.get("HACKAGENT_BUILD_VERSION", "")
_runtime_hook = BUILD_DIR / "hackagent_runtime_version.py"
BUILD_DIR.mkdir(parents=True, exist_ok=True)
_runtime_hook.write_text(
"import os\n"
f"os.environ.setdefault('HACKAGENT_BUILD_VERSION', {_build_version!r})\n"
)

a = Analysis(
[str(PROJECT_ROOT / "packaging" / "entrypoint.py")],
pathex=[str(PROJECT_ROOT)],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
runtime_hooks=[str(_runtime_hook)],
excludes=["tkinter"],
noarchive=False,
)

pyz = PYZ(a.pure)

exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="hackagent",
console=True,
strip=False,
upx=False,
)

coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
name="hackagent",
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ docs = [
"toml>=0.10.2",
"packaging>=24,<27",
]
packaging = [
binaries = [
"pyinstaller>=6.0",
]

Expand Down
Loading
Loading