From 32919368a92ce7547bc0162609aa8fe21996d2c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 02:28:48 +0000 Subject: [PATCH 1/4] install.sh: don't abort when uv was installed via an external package manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `uv self update` exits non-zero when uv was installed through Homebrew/apt (it can't replace a binary it doesn't own). With `set -e` that aborted the whole installer before the CLI was ever installed. Swallow the failure and proceed — a package-managed uv is kept current by its manager anyway. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01L7SAdcQr3ifEiHQSz1jdS7 --- install.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index c884e24..af571d4 100755 --- a/install.sh +++ b/install.sh @@ -104,7 +104,11 @@ if ! command -v uv &>/dev/null; then exit 0 fi else - uv self update + # `uv self update` errors out when uv was installed via an external package + # manager (Homebrew, apt, …) — it can't replace a binary it doesn't own. That + # is not fatal to us: a managed uv is already kept current by its manager, so + # swallow the failure and proceed straight to installing the CLI. + uv self update 2>/dev/null || true uv tool install -U "$PACKAGE" --python "$PYTHON_VERSION" fi From 8dfe3ec7c7224987edb9b39dacac5d725566754c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 02:46:37 +0000 Subject: [PATCH 2/4] install.sh: prefer uv/pipx, and brew-install available system deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installer selection now prefers an existing uv, falls back to an existing pipx, and only bootstraps uv when neither is present (instead of always assuming uv). System-dependency handling now installs the missing optional deps via Homebrew when `brew` is available — but only the formulae brew actually carries (guarded by `brew info`), so an unknown formula can't fail the batch. Anything brew can't provide falls through to the existing platform-specific install advice, which is also used when brew is absent. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01L7SAdcQr3ifEiHQSz1jdS7 --- README.md | 11 +++++--- install.sh | 80 ++++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ed70dd8..857bab9 100644 --- a/README.md +++ b/README.md @@ -234,9 +234,11 @@ Requires Python 3.12+ (Homebrew brings its own; for pipx/uv see the `--python` h curl -LsSf https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | sh ``` -The [`install.sh`](install.sh) script bootstraps [uv](https://docs.astral.sh/uv/) if it -isn't already present, then runs `uv tool install` to put `assembly` on your `PATH`. Re-run -it any time to update to the latest version. +The [`install.sh`](install.sh) script installs `assembly` with whichever tool installer you +already have — [uv](https://docs.astral.sh/uv/) if present, otherwise [pipx](https://pipx.pypa.io) — +and bootstraps uv only when neither is found. It then installs the optional live-audio system +dependencies via [Homebrew](https://brew.sh) when `brew` is available, or prints the right +install command for your platform otherwise. Re-run it any time to update to the latest version. ### Homebrew (macOS / Linux) @@ -266,7 +268,8 @@ Only the live-audio commands need anything extra: `stream`, `dictate`, and `agen microphone capture and [`ffmpeg`](https://ffmpeg.org) on `PATH` to stream non-WAV audio; `assembly share` uses [`cloudflared`](https://github.com/cloudflare/cloudflared) for its public tunnel. Plain `transcribe` uploads your file directly and needs none of them. The -[`install.sh`](install.sh) script checks for these and prints the right install command when any are missing. +[`install.sh`](install.sh) script checks for these and installs them via Homebrew when `brew` is +available, otherwise printing the right install command for your platform. - Debian/Ubuntu: `sudo apt-get install libportaudio2 ffmpeg` - Fedora: `sudo dnf install portaudio ffmpeg` diff --git a/install.sh b/install.sh index af571d4..9be870f 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,8 @@ set -e # Exit on any error # Canonical installer for the AssemblyAI CLI (`assembly`). -# Installs the app as a uv tool, bootstrapping uv first if it is missing. +# Installs the app with uv (or pipx) if either is present, bootstrapping uv when +# neither is — then installs the optional system deps via Homebrew if available. PACKAGE="git+https://github.com/AssemblyAI/cli.git" PYTHON_VERSION="3.13" @@ -33,17 +34,41 @@ has_portaudio() { return 1 } -# Homebrew also pulls in ffmpeg, portaudio, and cloudflared. The uv install does -# not, so detect any that are missing and print how to install them — without -# touching the system or invoking sudo on the user's behalf. -advise_system_deps() { - local missing=() - command -v ffmpeg >/dev/null 2>&1 || missing+=("ffmpeg") - has_portaudio || missing+=("portaudio") - command -v cloudflared >/dev/null 2>&1 || missing+=("cloudflared") +# Populate MISSING_DEPS with the optional system deps not already on the system. +MISSING_DEPS=() +detect_missing_deps() { + MISSING_DEPS=() + command -v ffmpeg >/dev/null 2>&1 || MISSING_DEPS+=("ffmpeg") + has_portaudio || MISSING_DEPS+=("portaudio") + command -v cloudflared >/dev/null 2>&1 || MISSING_DEPS+=("cloudflared") +} + +# Homebrew also pulls in ffmpeg, portaudio, and cloudflared. The uv/pipx installs +# do not, so detect any that are missing. If Homebrew is available we install the +# ones it actually carries (brew needs no sudo); for anything left we print how to +# install it — without touching the system or invoking sudo on the user's behalf. +install_system_deps() { + detect_missing_deps + [ ${#MISSING_DEPS[@]} -eq 0 ] && return 0 - [ ${#missing[@]} -eq 0 ] && return 0 + if command -v brew >/dev/null 2>&1; then + # Only ask Homebrew for formulae it actually has, so an unavailable one + # can't fail the whole batch; `brew info` exits non-zero for unknown names. + local brew_pkgs=() dep + for dep in "${MISSING_DEPS[@]}"; do + brew info --formula "$dep" >/dev/null 2>&1 && brew_pkgs+=("$dep") + done + if [ ${#brew_pkgs[@]} -gt 0 ]; then + echo "" + echo "Installing optional system dependencies with Homebrew: ${brew_pkgs[*]}" + brew install "${brew_pkgs[@]}" || true + fi + # Re-detect so we only advise on whatever brew couldn't provide. + detect_missing_deps + [ ${#MISSING_DEPS[@]} -eq 0 ] && return 0 + fi + local missing=("${MISSING_DEPS[@]}") echo "" echo "Optional system dependencies are missing: ${missing[*]}" echo "(core 'assembly transcribe' works without them)" @@ -90,29 +115,42 @@ advise_system_deps() { esac } -if ! command -v uv &>/dev/null; then - echo "uv is not installed. Installing..." +# Install `assembly` as an isolated tool. Prefer uv (it manages an isolated +# Python for us), then fall back to an existing pipx, and only bootstrap uv if +# neither is already present. +install_with_uv() { + # "$1" is the uv executable to invoke. + "$1" tool install -U "$PACKAGE" --python "$PYTHON_VERSION" +} + +if command -v uv >/dev/null 2>&1; then + # `uv self update` errors out when uv was installed via an external package + # manager (Homebrew, apt, …) — it can't replace a binary it doesn't own. That + # is not fatal to us: a managed uv is already kept current by its manager, so + # swallow the failure and proceed straight to installing the CLI. + uv self update 2>/dev/null || true + install_with_uv uv +elif command -v pipx >/dev/null 2>&1; then + # --force makes a re-run upgrade in place: the git source's version may not + # change between commits, so a plain `pipx install` would refuse as "already + # installed" and never pick up new code. + pipx install --force "$PACKAGE" +else + echo "Neither uv nor pipx found. Installing uv..." curl -LsSf https://astral.sh/uv/install.sh | sh echo "uv installation complete!" echo "" if [ -x "$HOME/.local/bin/uv" ]; then - "$HOME/.local/bin/uv" tool install -U "$PACKAGE" --python "$PYTHON_VERSION" + install_with_uv "$HOME/.local/bin/uv" else echo "Please restart your shell and run this script again" echo "" exit 0 fi -else - # `uv self update` errors out when uv was installed via an external package - # manager (Homebrew, apt, …) — it can't replace a binary it doesn't own. That - # is not fatal to us: a managed uv is already kept current by its manager, so - # swallow the failure and proceed straight to installing the CLI. - uv self update 2>/dev/null || true - uv tool install -U "$PACKAGE" --python "$PYTHON_VERSION" fi -advise_system_deps || true +install_system_deps || true echo "" echo "For help and support, see the AssemblyAI CLI repository" From 54ce778de61540e70a533228f5f7f0ce4ee2dd84 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 04:16:35 +0000 Subject: [PATCH 3/4] install.sh: add dev (editable) mode; CI smoke-tests the installer install.sh: - Add a development install mode: --install-method git (aliases --dev/-e/ --editable/--git), with --dir and AAI_INSTALL_METHOD/AAI_GIT_DIR env knobs and --help. Dev mode reuses the checkout it runs from (or clones the repo) and installs it editable: `uv tool install -U -e ` (or `pipx install --force -e `). - Installer selection prefers uv, then pipx, then bootstraps uv. - System deps: brew-install only the formulae Homebrew actually carries (guarded by `brew info`), falling back to printed advice otherwise. ci.yml: new "install script smoke" job runs ./install.sh --install-method git (editable install of the PR checkout via uv) and smoke-tests `assembly --version` / `assembly --help`. tests/test_code_tui.py: harden the flaky spinner test (stop the live interval timer and pause before asserting the pinned elapsed readout) that was failing CI. README.md: document dev mode; switch the install one-liner to bash. Note: pushed with the gate bypassed at the user's request. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01L7SAdcQr3ifEiHQSz1jdS7 --- .github/workflows/ci.yml | 37 ++++++++++++ README.md | 18 +++++- install.sh | 122 +++++++++++++++++++++++++++++++++++++-- tests/test_code_tui.py | 6 ++ 4 files changed, 174 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a181d47..7a38cff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -316,3 +316,40 @@ jobs: python -m pip install -e . pip-audit # Append `--ignore-vuln ` to accept an unfixable transitive advisory. python -m pip_audit + + # End-to-end check that install.sh actually installs a working `assembly`. Runs + # the script in dev mode (--install-method git) so it installs *this* checkout + # editable via uv — exercising both the installer and the PR's own code — then + # smoke-tests the resulting CLI. Catches install.sh regressions (arg parsing, + # the uv/pipx selection, the editable path) that shellcheck alone can't. + install-script: + name: install script smoke + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false # no job pushes; don't leave the token in .git/config + fetch-depth: 0 # hatch-vcs derives the version from git history for the editable build + # Provide uv so install.sh takes its preferred (uv) path rather than + # bootstrapping it over the network. + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + # PortAudio + ffmpeg so `assembly --help` (which imports the full command + # tree) loads cleanly; also lets install.sh's dep check find them present. + - name: System deps (PortAudio + ffmpeg) + run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg + + - name: Run install.sh (editable, from this checkout) + run: ./install.sh --install-method git + + - name: Smoke-test the installed CLI + run: | + # uv tool installs land in ~/.local/bin; put it on PATH for this step. + export PATH="$HOME/.local/bin:$PATH" + assembly --version + help_out="$(assembly --help)" + echo "$help_out" | grep -q transcribe diff --git a/README.md b/README.md index 857bab9..8de89fc 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,10 @@ Learn more about the platform in the [AssemblyAI docs](https://www.assemblyai.co Install on macOS or Linux with one command: ```sh -curl -LsSf https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | sh +curl -LsSf https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | bash ``` -This installs [uv](https://docs.astral.sh/uv/) if needed, then installs `assembly` as a uv tool. +This installs `assembly` with [uv](https://docs.astral.sh/uv/) (or pipx), bootstrapping uv if needed. Sign in (stores your API key in the OS keyring) and run your first transcription: @@ -231,7 +231,7 @@ Requires Python 3.12+ (Homebrew brings its own; for pipx/uv see the `--python` h ### Install script (recommended — macOS / Linux) ```sh -curl -LsSf https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | sh +curl -LsSf https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | bash ``` The [`install.sh`](install.sh) script installs `assembly` with whichever tool installer you @@ -240,6 +240,18 @@ and bootstraps uv only when neither is found. It then installs the optional live dependencies via [Homebrew](https://brew.sh) when `brew` is available, or prints the right install command for your platform otherwise. Re-run it any time to update to the latest version. +For a **development install** — an editable checkout so local source edits take effect without +reinstalling (`uv tool install -e .`) — pass `--install-method git` (or `--dev`). It reuses the +checkout you run it from, or clones the repo to `~/.local/share/assembly-cli` (override with +`--dir`): + +```sh +# from a clone you already have +./install.sh --dev +# or fetch + editable-install in one shot +curl -LsSf https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | bash -s -- --install-method git +``` + ### Homebrew (macOS / Linux) ```sh diff --git a/install.sh b/install.sh index 9be870f..b87e864 100755 --- a/install.sh +++ b/install.sh @@ -3,12 +3,93 @@ set -e # Exit on any error # Canonical installer for the AssemblyAI CLI (`assembly`). -# Installs the app with uv (or pipx) if either is present, bootstrapping uv when -# neither is — then installs the optional system deps via Homebrew if available. +# +# Default: installs the latest published code as an isolated tool with uv (or +# pipx), bootstrapping uv when neither is present. +# Dev mode (--install-method git / --dev): clones the repo (or reuses the +# checkout you run this from) and installs it editable (`uv tool install -e .`), +# so local source edits take effect without reinstalling. +# Either way it then installs the optional system deps via Homebrew if available. +# +# Usage: +# curl -LsSf https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | bash +# ./install.sh --dev # editable, from a clone +# curl -LsSf .../install.sh | bash -s -- --install-method git -PACKAGE="git+https://github.com/AssemblyAI/cli.git" +REPO_URL="https://github.com/AssemblyAI/cli.git" +PACKAGE="git+${REPO_URL}" PYTHON_VERSION="3.13" +# Install method: "release" (default, publish-style) or "git" (editable clone). +# Overridable by env or the flags parsed below. +INSTALL_METHOD="${AAI_INSTALL_METHOD:-release}" +GIT_DIR="${AAI_GIT_DIR:-$HOME/.local/share/assembly-cli}" +# Passed to the installer as `-e` only in dev mode (empty array otherwise). +EDITABLE=() + +usage() { + cat <<'EOF' +Install the AssemblyAI CLI (assembly). + +Usage: install.sh [options] + +Options: + --install-method release (default): install the latest + published code. git: clone the repo and + install it editable (development mode). + --dev, -e, --editable, --git Shortcut for --install-method git. + --release Shortcut for --install-method release. + --dir Clone directory for dev mode + (default: ~/.local/share/assembly-cli). + -h, --help Show this help. + +Environment: + AAI_INSTALL_METHOD=release|git Same as --install-method. + AAI_GIT_DIR= Same as --dir. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --install-method | --method) + [ $# -ge 2 ] || { + echo "Missing value for $1" >&2 + exit 2 + } + INSTALL_METHOD="$2" + shift + ;; + --dev | -e | --editable | --git) INSTALL_METHOD="git" ;; + --release | --published) INSTALL_METHOD="release" ;; + --dir | --git-dir) + [ $# -ge 2 ] || { + echo "Missing value for $1" >&2 + exit 2 + } + GIT_DIR="$2" + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +case "$INSTALL_METHOD" in +release | git) ;; +*) + echo "Invalid --install-method: $INSTALL_METHOD (use 'release' or 'git')" >&2 + exit 2 + ;; +esac + # Best-effort check for the PortAudio shared library (no `command` to probe, so # look via pkg-config, the dynamic linker cache, then well-known lib paths). has_portaudio() { @@ -115,14 +196,43 @@ install_system_deps() { esac } +# Resolve the source for a development (editable) install: reuse the checkout we +# are run from if it is the CLI repo, otherwise clone/update GIT_DIR. Sets PACKAGE +# to the local path and EDITABLE so the installer passes `-e`. +prepare_git_source() { + if [ -f pyproject.toml ] && grep -q '^name = "aai-cli"' pyproject.toml 2>/dev/null; then + PACKAGE="$(pwd)" + echo "Development install from current checkout: $PACKAGE" + else + if ! command -v git >/dev/null 2>&1; then + echo "Development install needs git to clone $REPO_URL" >&2 + exit 1 + fi + if [ -d "$GIT_DIR/.git" ]; then + echo "Updating existing clone at $GIT_DIR" + git -C "$GIT_DIR" pull --ff-only + else + echo "Cloning $REPO_URL to $GIT_DIR" + mkdir -p "$(dirname "$GIT_DIR")" + git clone "$REPO_URL" "$GIT_DIR" + fi + PACKAGE="$GIT_DIR" + echo "Development install from $PACKAGE" + fi + EDITABLE=(-e) +} + # Install `assembly` as an isolated tool. Prefer uv (it manages an isolated # Python for us), then fall back to an existing pipx, and only bootstrap uv if -# neither is already present. +# neither is already present. EDITABLE is empty for a release install and `-e` +# for a dev install. install_with_uv() { # "$1" is the uv executable to invoke. - "$1" tool install -U "$PACKAGE" --python "$PYTHON_VERSION" + "$1" tool install -U "${EDITABLE[@]}" "$PACKAGE" --python "$PYTHON_VERSION" } +[ "$INSTALL_METHOD" = "git" ] && prepare_git_source + if command -v uv >/dev/null 2>&1; then # `uv self update` errors out when uv was installed via an external package # manager (Homebrew, apt, …) — it can't replace a binary it doesn't own. That @@ -134,7 +244,7 @@ elif command -v pipx >/dev/null 2>&1; then # --force makes a re-run upgrade in place: the git source's version may not # change between commits, so a plain `pipx install` would refuse as "already # installed" and never pick up new code. - pipx install --force "$PACKAGE" + pipx install --force "${EDITABLE[@]}" "$PACKAGE" else echo "Neither uv nor pipx found. Installing uv..." curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/tests/test_code_tui.py b/tests/test_code_tui.py index c444cf3..a5fad12 100644 --- a/tests/test_code_tui.py +++ b/tests/test_code_tui.py @@ -286,8 +286,14 @@ async def go() -> None: await pilot.pause() assert app.query_one("#spinner", Static).display is True # _tick wires the elapsed seconds off the start time; pin "now" to assert it. + # Stop the live interval first (and pause after the pinned tick) so only this + # deterministic tick writes the readout — otherwise a real-time auto-tick can + # race the assert on a loaded runner, which flaked CI with "(6s)" vs "(7s)". + assert app._spin_timer is not None + app._spin_timer.stop() monkeypatch.setattr(time, "monotonic", lambda: app._turn_started + 7.0) app._tick() + await pilot.pause() assert "Working… (7s)" in str(app.query_one("#spinner", Static).render()) app._stop_spinner() assert app.query_one("#spinner", Static).display is False From 3e7887c1f771cf8097ad33002435f4fc2fcf9af5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 04:38:56 +0000 Subject: [PATCH 4/4] test_code_tui: fix spinner test deadlock from the flake hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous hardening added `await pilot.pause()` after stopping the spin timer, which deadlocks pilot — the test hung and wedged the whole pytest job until CI's 15-minute timeout cancelled it (failing the run). Stopping the live interval timer already removes the real-time tick that raced the assertion, and Static.update()->render() is synchronous, so the pause was both unnecessary and harmful. Drop it. Full suite passes with no hang. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01L7SAdcQr3ifEiHQSz1jdS7 --- tests/test_code_tui.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_code_tui.py b/tests/test_code_tui.py index 682dc11..fa0aeff 100644 --- a/tests/test_code_tui.py +++ b/tests/test_code_tui.py @@ -361,14 +361,14 @@ async def go() -> None: await pilot.pause() assert app.query_one("#spinner", Static).display is True # _tick wires the elapsed seconds off the start time; pin "now" to assert it. - # Stop the live interval first (and pause after the pinned tick) so only this - # deterministic tick writes the readout — otherwise a real-time auto-tick can - # race the assert on a loaded runner, which flaked CI with "(6s)" vs "(7s)". + # Stop the live interval first so only this deterministic tick writes the + # readout — otherwise a real-time auto-tick can race the assert on a loaded + # runner, which flaked CI with "(6s)" vs "(7s)". update()->render() is + # synchronous, so no pilot.pause() is needed (and pausing here deadlocks). assert app._spin_timer is not None app._spin_timer.stop() monkeypatch.setattr(time, "monotonic", lambda: app._turn_started + 7.0) app._tick() - await pilot.pause() assert "Working… (7s)" in str(app.query_one("#spinner", Static).render()) app._stop_spinner() assert app.query_one("#spinner", Static).display is False