Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,40 @@ jobs:
python -m pip install -e . pip-audit
# Append `--ignore-vuln <ID>` 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
29 changes: 22 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -231,12 +231,26 @@ 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 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.

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)

Expand Down Expand Up @@ -266,7 +280,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`
Expand Down
188 changes: 170 additions & 18 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,93 @@
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.
#
# 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|git> 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 <path> 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=<path> 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() {
Expand All @@ -33,17 +115,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.
Comment on lines +129 to +130

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The install_system_deps description says it works “without touching the system,” but the same function runs brew install, so the stated behavior contradicts actual execution.

Suggested change
# 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.
# ones it actually carries (brew needs no sudo); for anything Homebrew can't provide
# we print how to install it without touching the system or invoking sudo on the user's behalf.
Details

✨ AI Reasoning
​The updated dependency-install path now performs package installation through Homebrew. However, the accompanying explanatory text still states that this path operates without modifying the system. Those two statements cannot both be true at runtime, so the documented assumption is impossible to satisfy given the control flow.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

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)"
Expand Down Expand Up @@ -90,25 +196,71 @@ advise_system_deps() {
esac
}

if ! command -v uv &>/dev/null; then
echo "uv is not installed. Installing..."
# 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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Echo prints the user-derived PACKAGE value (path/URL). Avoid logging raw user-controlled paths; consider redacting or omitting sensitive details.

Details

✨ AI Reasoning
​The change adds multiple echo/logging statements that output values originating from user-controllable sources: PACKAGE may be set to the current working directory or a clone path, and GIT_DIR can be provided via environment or CLI flags. Printing these paths can expose filesystem locations or other personal data. This is a newly introduced behavior in the changed code and increases risk of leaking user-controlled/personal data to stdout.

🔧 How do I fix it?
Keep sensitive data such as emails, passwords, and tokens out of logs. When logging values tied to a user, prefer a safe identifier like a user ID over the raw input, and strip line breaks from any user-provided text you do log.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

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. 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 "${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
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uv self update 2>/dev/null || true suppresses errors and output; consider logging failures instead of swallowing them to avoid hiding unexpected or malicious behavior.

Details

✨ AI Reasoning
​The change intentionally redirects or swallows error output for operations that execute external code (uv self update and brew install). Silencing stderr and forcing a successful exit code removes visible failure signals and can hide unexpected or malicious behavior from reviewers and users. This reduces transparency of what the installer actually did and makes post-failure investigation harder.

🔧 How do I fix it?
Ensure code is transparent and not intentionally obfuscated. Avoid hiding functionality from code review. Focus on intent and deception, not specific patterns.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

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 "${EDITABLE[@]}" "$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
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"
Expand Down
6 changes: 6 additions & 0 deletions tests/test_code_tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,12 @@ 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 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()
assert "Working… (7s)" in str(app.query_one("#spinner", Static).render())
Expand Down
Loading