Packaging for Debian / Ubuntu

August 9, 2026 · View on GitHub

scripts/build-deb.sh                     # -> dist/ai-commander-2026_<version>_amd64.deb
sudo dpkg -i dist/ai-commander-2026_*.deb
ai-commander --check

Roughly 23 MB packaged, ~120 MB installed. Verified end to end on Ubuntu with Python 3.14.4 on 2026-08-09: build, install, run under a real terminal, remove, reinstall.

Layout

PathWhat
/opt/ai-commander-2026/A self-contained virtualenv — interpreter symlink, all dependencies, the app
/usr/bin/ai-commanderTwo-line sh launcher that execs the venv's entry point
/usr/share/applications/ai-commander-2026.desktopMenu entry, Terminal=true
/usr/share/doc/ai-commander-2026/README, copyright, changelog

Why a bundled venv rather than python3-* dependencies

The app needs numpy, sounddevice, soundfile, textual, edge-tts and (optionally) google-genai. Some of those have no Debian package, the ones that do lag the versions the tests ran against, and mixing archive packages with pip installs into /usr/lib/python3/dist-packages is how you get an unreproducible machine. Bundling gives the packaged app exactly the dependency set that was tested.

/opt is the correct home for this. lintian will say otherwise (dir-or-file-in-opt) because that rule is written for packages entering the Debian archive; the build script suppresses it deliberately.

The interpreter constraint — the thing to get right

The venv is built against the system python3, not a uv-managed one, so the wheels inside are ABI-tagged for that exact minor version (cp314 on the build machine here). The package therefore declares:

Depends: python3 (>= 3.14), python3 (<< 3.15), libportaudio2, espeak-ng

Both bounds are generated from the build machine's interpreter, so the control file cannot drift from what is actually in the venv. Build on the oldest release you intend to support — the package will not install on a machine whose python3 is a different minor version, and that is correct behaviour rather than a limitation to work around.

If you ever need one .deb that spans releases, the fix is to bundle a standalone interpreter too, not to loosen the dependency.

Relocatability

uv venv --relocatable is what makes this work. Without it, every console script in bin/ gets a shebang hard-coded to the build-time path, which is a temporary staging directory and does not exist on the target. With it, the scripts get a polyglot header that resolves the interpreter relative to the script:

#!/bin/sh
'''exec' "$(dirname -- "$(realpath -- "\$0")")"/'python' "\$0" "$@"
' '''

That is valid and it works — but lintian cannot parse it and reports shell-script-fails-syntax-check for every script in bin/. Expected; ignore.

Expected lintian output

All of these are inherent to shipping compiled wheels and can be ignored. None of them indicate a broken package.

TagCause
dir-or-file-in-optSuppressed in the build script — /opt is the right place here
shell-script-fails-syntax-checkuv's relocatable shebang, above
missing-dependency-on-libcWheel .so files, not built by us
unstripped-binary-or-objectDitto
shared-library-is-executablenumpy's bundled BLAS/gfortran libraries

Build options

EXTRAS=openrouter scripts/build-deb.sh      # default
EXTRAS=openrouter,gemini scripts/build-deb.sh
EXTRAS= scripts/build-deb.sh                # no ASR provider — typed input only
OUTDIR=/tmp scripts/build-deb.sh
PYTHON=/usr/bin/python3.12 scripts/build-deb.sh

The local (faster-whisper) extra is deliberately not in the default build: it pulls in CTranslate2 and would roughly triple the package size for a feature most installs will not use. Add it after installing if you want it:

sudo /opt/ai-commander-2026/bin/python -m pip install faster-whisper

Testing an installed package

The package does not ship the test suite, so test it as a user would:

cd /tmp                                  # prove it does not depend on the repo
ai-commander --check                     # engines, devices, ASR provider
ai-commander --list-scripts
script -qec "timeout 10 ai-commander --no-mic --no-audio" /dev/null

For a headless functional check, drive the installed app through Textual's pilot using the packaged interpreter:

/opt/ai-commander-2026/bin/python - <<'PY'
import asyncio
from ai_commander.config import load_config
from ai_commander.ui.app import CommanderApp
cfg = load_config(); cfg.audio.enabled = cfg.mic.enabled = False

async def main():
    app = CommanderApp(cfg, use_mic=False)
    async with app.run_test(size=(100, 26)) as pilot:
        await pilot.pause(); await asyncio.sleep(12)
        app.handle_transcript("claude the wifi is slow again", spoken=False)
        await asyncio.sleep(10)
        print(app._exception, app.voice.engine_name, len(app.voice._cache))
asyncio.run(main())
PY

cfg.audio.enabled = False still renders speech, it just does not play it — which is what you want when checking that edge-tts works without filling the room.

Removal

sudo dpkg -r ai-commander-2026

prerm clears the __pycache__ directories the venv writes on first run, so /opt/ai-commander-2026 is removed completely rather than left behind with stray bytecode — the usual failure mode for bundled-venv packages.