From 0a7d34b3afefdc72fe56c64fad93a49043b9c9ab Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:45:10 +0000 Subject: [PATCH 1/3] build(packaging): ship standalone binaries with GitHub releases --- .github/workflows/publish.yml | 114 ++++++++++++++++++++++++++++-- README.md | 21 ++++++ hackagent/_version.py | 21 ++++++ hackagent/cli/bootstrap.py | 4 +- hackagent/cli/help_page.py | 6 +- hackagent/cli/main.py | 10 +-- hackagent/cli/tui/views/config.py | 5 +- packaging/entrypoint.py | 12 ++++ pyproject.toml | 2 +- tests/unit/test_version.py | 43 +++++++++++ uv.lock | 8 +-- 11 files changed, 221 insertions(+), 25 deletions(-) create mode 100644 hackagent/_version.py create mode 100644 packaging/entrypoint.py create mode 100644 tests/unit/test_version.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f103e12d..1b1db144 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,12 +37,116 @@ 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 \ No newline at end of file + prerelease: false + + build-binaries: + name: Build binary (${{ matrix.target }}) + 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 }} diff --git a/README.md b/README.md index f009bded..510f43ae 100644 --- a/README.md +++ b/README.md @@ -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--linux-x86_64.tar.gz` | +| macOS arm64 | `hackagent--macos-arm64.tar.gz` | +| macOS x86_64 | `hackagent--macos-x86_64.tar.gz` | +| Windows x86_64 | `hackagent--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. diff --git a/hackagent/_version.py b/hackagent/_version.py new file mode 100644 index 00000000..bf6d91fe --- /dev/null +++ b/hackagent/_version.py @@ -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 diff --git a/hackagent/cli/bootstrap.py b/hackagent/cli/bootstrap.py index bd57e91c..d10a4e5f 100644 --- a/hackagent/cli/bootstrap.py +++ b/hackagent/cli/bootstrap.py @@ -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") diff --git a/hackagent/cli/help_page.py b/hackagent/cli/help_page.py index 2c1d6106..5a7378f0 100644 --- a/hackagent/cli/help_page.py +++ b/hackagent/cli/help_page.py @@ -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() @@ -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( diff --git a/hackagent/cli/main.py b/hackagent/cli/main.py index 279ed873..29c0edce 100644 --- a/hackagent/cli/main.py +++ b/hackagent/cli/main.py @@ -7,7 +7,6 @@ Main command-line interface for HackAgent security testing toolkit. """ -import importlib.metadata import importlib.util import os @@ -15,6 +14,7 @@ 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, @@ -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) @@ -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]" ) diff --git a/hackagent/cli/tui/views/config.py b/hackagent/cli/tui/views/config.py index f03eb0d7..22c69fff 100644 --- a/hackagent/cli/tui/views/config.py +++ b/hackagent/cli/tui/views/config.py @@ -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 @@ -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", diff --git a/packaging/entrypoint.py b/packaging/entrypoint.py new file mode 100644 index 00000000..d565d4d2 --- /dev/null +++ b/packaging/entrypoint.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 633fc1df..9e513c97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ docs = [ "toml>=0.10.2", "packaging>=24,<27", ] -packaging = [ +binaries = [ "pyinstaller>=6.0", ] diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py new file mode 100644 index 00000000..8f8d2c04 --- /dev/null +++ b/tests/unit/test_version.py @@ -0,0 +1,43 @@ +# Copyright 2026 - AI4I. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the frozen-binary friendly version lookup.""" + +from importlib.metadata import PackageNotFoundError + +from hackagent import _version + + +def test_get_version_uses_distribution_metadata(): + assert _version.get_version() == _version._distribution_version("hackagent") + + +def test_get_version_falls_back_to_build_version(monkeypatch): + def _raise(_name): + raise PackageNotFoundError("hackagent") + + monkeypatch.setattr(_version, "_distribution_version", _raise) + monkeypatch.setenv("HACKAGENT_BUILD_VERSION", "1.2.3") + + assert _version.get_version() == "1.2.3" + + +def test_get_version_falls_back_to_unknown(monkeypatch): + def _raise(_name): + raise PackageNotFoundError("hackagent") + + monkeypatch.setattr(_version, "_distribution_version", _raise) + monkeypatch.delenv("HACKAGENT_BUILD_VERSION", raising=False) + + assert _version.get_version() == _version.UNKNOWN_VERSION + + +def test_cli_version_flag_reports_version(): + from click.testing import CliRunner + + from hackagent.cli.main import cli + + result = CliRunner().invoke(cli, ["--version"]) + + assert result.exit_code == 0 + assert _version.get_version() in result.output diff --git a/uv.lock b/uv.lock index 60e63225..36cc9d91 100644 --- a/uv.lock +++ b/uv.lock @@ -1892,6 +1892,9 @@ dependencies = [ ] [package.dev-dependencies] +binaries = [ + { name = "pyinstaller" }, +] dev = [ { name = "anyio" }, { name = "commitizen" }, @@ -1916,9 +1919,6 @@ docs = [ { name = "pydoc-markdown" }, { name = "toml" }, ] -packaging = [ - { name = "pyinstaller" }, -] [package.metadata] requires-dist = [ @@ -1940,6 +1940,7 @@ requires-dist = [ ] [package.metadata.requires-dev] +binaries = [{ name = "pyinstaller", specifier = ">=6.0" }] dev = [ { name = "anyio", specifier = ">=4.3.0" }, { name = "commitizen", specifier = ">=4.7.1" }, @@ -1964,7 +1965,6 @@ docs = [ { name = "pydoc-markdown", specifier = ">=4.8.2" }, { name = "toml", specifier = ">=0.10.2" }, ] -packaging = [{ name = "pyinstaller", specifier = ">=6.0" }] [[package]] name = "hf-xet" From d545744034a101627103b04a5334ff5c2fbf7265 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:45:41 +0000 Subject: [PATCH 2/3] build(packaging): check in the PyInstaller release spec --- .gitignore | 2 + packaging/hackagent.spec | 87 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 packaging/hackagent.spec diff --git a/.gitignore b/.gitignore index 355c23d5..ce488a7c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packaging/hackagent.spec b/packaging/hackagent.spec new file mode 100644 index 00000000..c142a861 --- /dev/null +++ b/packaging/hackagent.spec @@ -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", +) From 859c303a7917bda7e4c27842c53f9f14630e57b0 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:47:57 +0000 Subject: [PATCH 3/3] ci(publish): gate binary asset upload on the release job --- .github/workflows/publish.yml | 3 +++ tests/unit/test_version.py | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1b1db144..9ad28cb7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -47,6 +47,9 @@ jobs: 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: diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py index 8f8d2c04..6ed9593e 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_version.py @@ -8,8 +8,11 @@ from hackagent import _version -def test_get_version_uses_distribution_metadata(): - assert _version.get_version() == _version._distribution_version("hackagent") +def test_get_version_prefers_distribution_metadata(monkeypatch): + monkeypatch.setattr(_version, "_distribution_version", lambda _name: "9.9.9") + monkeypatch.setenv("HACKAGENT_BUILD_VERSION", "1.2.3") + + assert _version.get_version() == "9.9.9" def test_get_version_falls_back_to_build_version(monkeypatch):