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
| Path | What |
|---|---|
/opt/ai-commander-2026/ | A self-contained virtualenv — interpreter symlink, all dependencies, the app |
/usr/bin/ai-commander | Two-line sh launcher that execs the venv's entry point |
/usr/share/applications/ai-commander-2026.desktop | Menu 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.
| Tag | Cause |
|---|---|
dir-or-file-in-opt | Suppressed in the build script — /opt is the right place here |
shell-script-fails-syntax-check | uv's relocatable shebang, above |
missing-dependency-on-libc | Wheel .so files, not built by us |
unstripped-binary-or-object | Ditto |
shared-library-is-executable | numpy'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.