Goal
Ship a standalone hackagent executable per OS/arch attached to each GitHub Release, so a user can download one file and run hackagent with no Python/pip/uv install — the same experience uv, ruff, and gh itself provide. Today publish.yml already creates a GitHub Release on every v* tag (via actions/create-release@v1, right after uv build + uv publish to PyPI), but it carries no binary assets — the release page just links back to PyPI. This issue is about adding those assets to that same release.
Proposed approach: PyInstaller one-file build in a CI matrix
hackagent is a Click app ([project.scripts] hackagent = "hackagent.cli.main:cli" in pyproject.toml) that defaults to launching a Textual TUI (_launch_tui_default in hackagent/cli/bootstrap.py) when invoked with no subcommand. PyInstaller is the standard tool for this shape of app (pure-Python CLI + native-extension deps + TUI) and is what most comparable projects use for one-off no-python-required binaries.
Add a build-binaries job to .github/workflows/publish.yml, gated on the same v* tag push, running in parallel with the existing publish job:
build-binaries:
strategy:
matrix:
include:
- os: ubuntu-latest
target: linux-x86_64
- os: macos-latest
target: macos-arm64
- os: macos-13 # last Intel macos-* runner
target: macos-x86_64
- os: windows-latest
target: windows-x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with: { version: "0.10.11" }
- run: uv sync --extra pyinstaller # see "new dependency" below
- run: uv run pyinstaller packaging/hackagent.spec
- run: <rename dist artifact to hackagent-${{ matrix.target }}[.exe]>
- uses: actions/upload-release-asset@v1 # or softprops/action-gh-release, see note
The publish job's actions/create-release@v1 step is deprecated/unmaintained and only returns an upload URL awkwardly for matrix jobs; switching both jobs to softprops/action-gh-release@v2 (actively maintained, supports a files: glob and can be called once per matrix leg with append_body: true) is worth doing as part of this issue rather than fighting the old action across 4 parallel jobs.
Known packaging gotchas specific to this repo's dependency set
These need to be handled in the PyInstaller spec, not discovered at release time:
importlib.metadata.version("hackagent") is used both for --version and the version command (cli/main.py:72, hackagent/cli/main.py:260). A frozen PyInstaller binary has no installed distribution metadata by default — this raises PackageNotFoundError unless the spec adds --copy-metadata hackagent (or the code falls back to a version baked in at build time via an env var).
- Textual ships its own CSS/asset files that PyInstaller's default import hook doesn't always collect — needs
--collect-data textual. The app's own TUI code lives under hackagent/cli/tui/; worth double-checking during the build whether it loads any non-.py assets at runtime (fonts, icons) that also need --add-data.
faiss-cpu and other compiled-extension deps need the matrix build to run natively per target (no cross-compiling) — the matrix above already does this by using per-OS runners rather than QEMU/cross builds.
playwright (used by the WEB provider) requires a separate playwright install browser download regardless of packaging method — the binary cannot embed Chromium/Firefox. This should be called out explicitly wherever the binary is documented/installed (README/release notes), same caveat that exists for pip install hackagent today.
nicegui (local dashboard) serves its own static web assets from its package data — needs --collect-data nicegui, same class of issue as Textual.
- One-file mode (
--onefile) unpacks to a temp dir on every launch, which is slow for an app this size (~15 MB of first-party code before deps); --onedir (a folder + launcher) starts faster and is what most CLI-shaped PyInstaller distributions actually ship, at the cost of shipping a .zip/.tar.gz instead of a single file. Worth deciding onedir-in-an-archive vs true onefile before wiring the workflow, since it changes the release-asset naming and the install instructions.
New dev dependency
Add pyinstaller as an optional dependency group (e.g. [dependency-groups] packaging = ["pyinstaller>=6.0"] in pyproject.toml), not to the default dev group — it's a release-time tool the day-to-day contributor doesn't need.
Release asset naming
Match the convention CLI tools like uv/ripgrep use so an install script can predict the filename from uname:
hackagent-${VERSION}-linux-x86_64.tar.gz
hackagent-${VERSION}-macos-arm64.tar.gz
hackagent-${VERSION}-macos-x86_64.tar.gz
hackagent-${VERSION}-windows-x86_64.zip
Optional follow-up: install script
Once assets exist with predictable names, a curl -fsSL https://.../install.sh | bash one-liner (resolve OS/arch, download the matching release asset, extract to ~/.local/bin or similar) is a natural next step — but it's additive and shouldn't block getting the binaries themselves published. Track it as a follow-up rather than in this issue's acceptance criteria.
Acceptance criteria
Goal
Ship a standalone
hackagentexecutable per OS/arch attached to each GitHub Release, so a user can download one file and runhackagentwith no Python/pip/uvinstall — the same experienceuv,ruff, andghitself provide. Todaypublish.ymlalready creates a GitHub Release on everyv*tag (viaactions/create-release@v1, right afteruv build+uv publishto PyPI), but it carries no binary assets — the release page just links back to PyPI. This issue is about adding those assets to that same release.Proposed approach: PyInstaller one-file build in a CI matrix
hackagentis a Click app ([project.scripts]hackagent = "hackagent.cli.main:cli"inpyproject.toml) that defaults to launching a Textual TUI (_launch_tui_defaultinhackagent/cli/bootstrap.py) when invoked with no subcommand. PyInstaller is the standard tool for this shape of app (pure-Python CLI + native-extension deps + TUI) and is what most comparable projects use for one-off no-python-required binaries.Add a
build-binariesjob to.github/workflows/publish.yml, gated on the samev*tag push, running in parallel with the existingpublishjob:The
publishjob'sactions/create-release@v1step is deprecated/unmaintained and only returns an upload URL awkwardly for matrix jobs; switching both jobs tosoftprops/action-gh-release@v2(actively maintained, supports afiles:glob and can be called once per matrix leg withappend_body: true) is worth doing as part of this issue rather than fighting the old action across 4 parallel jobs.Known packaging gotchas specific to this repo's dependency set
These need to be handled in the PyInstaller spec, not discovered at release time:
importlib.metadata.version("hackagent")is used both for--versionand theversioncommand (cli/main.py:72,hackagent/cli/main.py:260). A frozen PyInstaller binary has no installed distribution metadata by default — this raisesPackageNotFoundErrorunless the spec adds--copy-metadata hackagent(or the code falls back to a version baked in at build time via an env var).--collect-data textual. The app's own TUI code lives underhackagent/cli/tui/; worth double-checking during the build whether it loads any non-.pyassets at runtime (fonts, icons) that also need--add-data.faiss-cpuand other compiled-extension deps need the matrix build to run natively per target (no cross-compiling) — the matrix above already does this by using per-OS runners rather than QEMU/cross builds.playwright(used by theWEBprovider) requires a separateplaywright installbrowser download regardless of packaging method — the binary cannot embed Chromium/Firefox. This should be called out explicitly wherever the binary is documented/installed (README/release notes), same caveat that exists forpip install hackagenttoday.nicegui(local dashboard) serves its own static web assets from its package data — needs--collect-data nicegui, same class of issue as Textual.--onefile) unpacks to a temp dir on every launch, which is slow for an app this size (~15 MB of first-party code before deps);--onedir(a folder + launcher) starts faster and is what most CLI-shaped PyInstaller distributions actually ship, at the cost of shipping a.zip/.tar.gzinstead of a single file. Worth deciding onedir-in-an-archive vs true onefile before wiring the workflow, since it changes the release-asset naming and the install instructions.New dev dependency
Add
pyinstalleras an optional dependency group (e.g.[dependency-groups] packaging = ["pyinstaller>=6.0"]inpyproject.toml), not to the defaultdevgroup — it's a release-time tool the day-to-day contributor doesn't need.Release asset naming
Match the convention CLI tools like
uv/ripgrepuse so an install script can predict the filename fromuname:Optional follow-up: install script
Once assets exist with predictable names, a
curl -fsSL https://.../install.sh | bashone-liner (resolve OS/arch, download the matching release asset, extract to~/.local/binor similar) is a natural next step — but it's additive and shouldn't block getting the binaries themselves published. Track it as a follow-up rather than in this issue's acceptance criteria.Acceptance criteria
packaging/hackagent.specPyInstaller spec covering the metadata/Textual/nicegui data-collection gotchas abovepyinstalleradded as its own dependency group, notdevbuild-binariesmatrix job inpublish.yml(Linux x86_64, macOS arm64 + x86_64, Windows x86_64) producing the 4 archives abovepublishandbuild-binariescan attach assets through (e.g.softprops/action-gh-release)hackagent --version/hackagent versionwork correctly from the frozen binary (noPackageNotFoundError)hackagent(no subcommand → TUI) and at least one subcommand (e.g.hackagent scan) smoke-tested from the built binary on each OS, not just import-testedplaywright installstep needed for theWEBprovider