diff --git a/.gitignore b/.gitignore
index 55197503..b08ec1f4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,9 @@ transcribe/
# Wrong tool for this project (hatchling + uv); never commit a poetry lock
poetry.lock
+# Generated by hatch-vcs at build time (version derived from the git tag)
+aai_cli/_version.py
+
# Brainstorming visual-companion scratch
.superpowers/
diff --git a/AGENTS.md b/AGENTS.md
index 38b127f9..812f327a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -154,7 +154,7 @@ the return value comes from a recorded payload instead of a hand-built mock.
- The **package/module** is `aai_cli`; the **distribution** name is `aai-cli`; the **console command** is `assembly` (`[project.scripts] assembly = "aai_cli.main:run"`).
- `assembly init` templates live in `aai_cli/init/templates/` and are **committed**, including renamed dotfiles (`gitignore` → `.gitignore`, `env.example`). The wheel force-includes them via `[tool.hatch.build.targets.wheel] artifacts`, excluding `__pycache__/*.pyc`. Editing templates needs care — see the parametrized contract tests (`tests/test_init_template_*.py`).
- `audioop` left the stdlib in 3.13; `audioop-lts` backfills it (conditional dependency). Supported Pythons: 3.12–3.13.
-- **Releasing is tag-triggered.** `.github/workflows/release.yml` fires on a pushed `vX.Y.Z` tag and builds the prebuilt arm64 Homebrew bottle (`Formula/assembly.rb`), cuts the GitHub Release, and opens the formula PR — bottling matters because the deps include Rust-backed sdists (`pydantic-core`, `jiter`, `cryptography`) that would otherwise compile from source on `brew install`. Two committed helpers drive it and are self-documenting (`--help`): `scripts/bump_patch.sh` rewrites the version in lock-step across `pyproject.toml` + `aai_cli/__init__.py` (run on a branch → merge the PR), then `scripts/cut_release.sh` tags + pushes. **`cut_release.sh` only runs from a clean `main` in sync with `origin/main`** (it hard-errors on a feature branch / dirty tree / version mismatch), so cut releases from `main`, not your working branch. The "update available" notice users see is `aai_cli/update_check.py`.
+- **Releasing is tag-triggered.** The version is **derived from the git tag** by hatch-vcs and written to a gitignored `aai_cli/_version.py` at build time — there is no version string to keep in sync across `pyproject.toml` or `aai_cli/__init__.py`, and `bump_patch.sh` no longer exists. To cut a release, run `scripts/cut_release.sh` from a clean `main` in sync with `origin/main`: no argument → next patch above the latest `vX.Y.Z` tag; `cut_release.sh X.Y.Z` → explicit version. It tags + pushes, which fires `.github/workflows/release.yml` — that builds the prebuilt arm64 Homebrew bottle (`Formula/assembly.rb`), cuts the GitHub Release, and opens the formula PR. Bottling matters because the deps include Rust-backed sdists (`pydantic-core`, `jiter`, `cryptography`) that would otherwise compile from source on `brew install`. The Homebrew formula builds from a git-less GitHub source tarball, so `Formula/assembly.rb`'s `def install` sets the generic `SETUPTOOLS_SCM_PRETEND_VERSION` env var (installing resources first under a clean env, then setting the var for our package only) to feed the tag version to the build. **`cut_release.sh` only runs from a clean `main` in sync with `origin/main`** (it hard-errors on a feature branch / dirty tree), so cut releases from `main`, not your working branch. The "update available" notice users see is `aai_cli/update_check.py`.
## Architecture
diff --git a/Formula/assembly.rb b/Formula/assembly.rb
index 58d6f642..77a3a373 100644
--- a/Formula/assembly.rb
+++ b/Formula/assembly.rb
@@ -288,7 +288,18 @@ class Assembly < Formula
end
def install
- virtualenv_install_with_resources
+ # The GitHub source tarball has no .git, so hatch-vcs cannot derive the
+ # version at build time. hatch-vcs (0.x) does not forward dist_name to
+ # setuptools-scm, so only the GENERIC SETUPTOOLS_SCM_PRETEND_VERSION is
+ # honored — but a global generic pretend-version would also override the
+ # version of any *resource* that builds via setuptools-scm. So install the
+ # pinned resources first under a clean env, then set the pretend-version and
+ # install only our own package (the build hook writes the tag version into
+ # the installed aai_cli/_version.py).
+ venv = virtualenv_create(libexec, "python3.13")
+ venv.pip_install resources
+ ENV["SETUPTOOLS_SCM_PRETEND_VERSION"] = version.to_s
+ venv.pip_install_and_link buildpath
end
test do
diff --git a/README.md b/README.md
index 27cd1aee..9279a73f 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,10 @@
The AssemblyAI CLI (`assembly`) brings speech AI to your terminal: transcribe files, URLs, and YouTube/podcast pages, stream live audio, talk to a two-way voice agent, prompt the LLM Gateway, benchmark speech models, and scaffold ready-to-deploy starter apps.
+
+
+
+
## 🚀 Why the AssemblyAI CLI?
- **🎯 One command for everything**: transcription, real-time streaming, voice agents, LLM prompts, and WER benchmarking — no SDK boilerplate.
@@ -15,6 +19,75 @@ The AssemblyAI CLI (`assembly`) brings speech AI to your terminal: transcribe fi
- **🤖 Agent-ready**: `assembly setup install` wires your coding agent up with the AssemblyAI docs MCP server and skills.
- **📖 Open source**: MIT licensed.
+## ✨ Things you can do with it
+
+A few one-liners that show what `assembly` can do. These are the fun ones; the everyday basics live under **Quick examples** below.
+
+**Recreate a scene with synthetic voices** — transcribe and diarize a YouTube clip, then pipe it straight into TTS with a different voice per speaker:
+
+```sh
+assembly transcribe "https://www.youtube.com/watch?v=awmCtXzFsJo" --speaker-labels \
+ | assembly --sandbox speak --voice A=jane --voice B=mary --out scene.wav
+```
+
+`speak` auto-detects `Speaker A:` labels, merges each speaker's turns, and rotates voices. (`speak` is sandbox-only today, hence `--sandbox`.)
+
+**Turn a podcast into audio** — Apple and Spotify podcast pages work too (yt-dlp ingestion):
+
+```sh
+assembly transcribe "https://podcasts.apple.com/us/podcast/id1516093381" --speaker-labels \
+ | assembly --sandbox speak --out episode.wav
+```
+
+**Keep a live to-do list from your mic** — `llm -f` re-runs the prompt over the growing transcript, updating in place:
+
+```sh
+assembly stream -o text | assembly llm -f "summarize my to-dos as I talk"
+```
+
+**Caption a meeting from system audio** (macOS) — captures app/system audio alongside your mic as separate diarized speakers:
+
+```sh
+assembly stream --system-audio --speaker-labels -o text
+```
+
+**Get pinged when your name comes up** in a live meeting:
+
+```sh
+assembly stream -o text | grep --line-buffered -i alex \
+ | while read -r _; do afplay /System/Library/Sounds/Glass.aiff; done
+```
+
+**Chain LLM prompts over a transcript** — each prompt runs on the finished transcript:
+
+```sh
+assembly transcribe --sample --llm "summarize" --llm "translate the summary to French"
+```
+
+**Talk to a voice agent in your terminal** — full-duplex, around 20 voices:
+
+```sh
+assembly agent --voice ivy --system-prompt "you're a helpful interviewer"
+```
+
+**Graduate to the SDK** — `--show-code` prints the equivalent Python script for any `transcribe`/`stream`/`agent` run instead of executing it:
+
+```sh
+assembly agent --system-prompt "you're a story generator" --show-code > story.py
+```
+
+**Scaffold and deploy a voice agent** — templates: `voice-agent`, `audio-transcription`, `live-captions`:
+
+```sh
+assembly init voice-agent && assembly deploy --prod
+```
+
+**Benchmark WER against public datasets** — built-in aliases for LibriSpeech, TEDLIUM, and more:
+
+```sh
+assembly eval librispeech --speech-model universal-3-pro --limit 50
+```
+
## 📦 Installation
Requires Python 3.12+ (Homebrew brings its own; for pipx/uv see the `--python` hint below).
diff --git a/aai_cli/__init__.py b/aai_cli/__init__.py
index bbab0242..f2b24670 100644
--- a/aai_cli/__init__.py
+++ b/aai_cli/__init__.py
@@ -1 +1,3 @@
-__version__ = "0.1.4"
+from aai_cli._version import __version__
+
+__all__ = ["__version__"]
diff --git a/aai_cli/main.py b/aai_cli/main.py
index 916d7f28..179a86ca 100644
--- a/aai_cli/main.py
+++ b/aai_cli/main.py
@@ -252,8 +252,8 @@ def _format_click_error_fixed(self: ClickException) -> None:
app = typer.Typer(
name="assembly",
- # No top-level `help=`: the bare-`assembly` welcome banner already carries the
- # "AssemblyAI from your terminal" tagline, so a description here would duplicate it.
+ # No top-level `help=`: the bare-`assembly` welcome banner plus the command table
+ # below already introduce the tool, so a description here would be redundant.
# `assembly --install-completion` / `--show-completion` for bash/zsh/fish/PowerShell,
# the discoverability affordance gh/kubectl/docker users reach for.
add_completion=True,
diff --git a/aai_cli/output.py b/aai_cli/output.py
index 39fd508d..5d2f5acf 100644
--- a/aai_cli/output.py
+++ b/aai_cli/output.py
@@ -225,18 +225,16 @@ def emit_error(err: CLIError, *, json_mode: bool) -> None:
error_console.print(f"[aai.muted]Suggestion:[/aai.muted] {escape(err.suggestion)}")
-# A one-line header: emoji + product + version, then the product tagline.
-_TAGLINE = "AssemblyAI from your terminal"
+# A one-line header: emoji + product + version.
def print_banner() -> None:
- """Print the welcome header — a single emoji + product + version + tagline line
- in the brand accent (the bare-command welcome screen)."""
- # highlight=False so Rich's repr-highlighter doesn't recolor the version digits or
- # the quoted tagline — the line stays a single muted tone behind the brand label.
+ """Print the welcome header — a single emoji + product + version line in the
+ brand accent (the bare-command welcome screen)."""
+ # highlight=False so Rich's repr-highlighter doesn't recolor the version digits —
+ # the line stays a single muted tone behind the brand label.
console.print(
- f"[aai.brand]🎙️ AssemblyAI CLI[/aai.brand] "
- f"[aai.muted]{__version__} — {_TAGLINE}[/aai.muted]",
+ f"[aai.brand]🎙️ AssemblyAI CLI[/aai.brand] [aai.muted]{__version__}[/aai.muted]",
highlight=False, # pragma: no mutate (purely cosmetic: toggles Rich repr coloring, not text)
)
diff --git a/assets/welcome.png b/assets/welcome.png
new file mode 100644
index 00000000..12700b74
Binary files /dev/null and b/assets/welcome.png differ
diff --git a/docs/superpowers/plans/2026-06-12-hatch-vcs-versioning.md b/docs/superpowers/plans/2026-06-12-hatch-vcs-versioning.md
new file mode 100644
index 00000000..4e7ad249
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-12-hatch-vcs-versioning.md
@@ -0,0 +1,386 @@
+# Tag-derived Versioning via hatch-vcs — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Derive the package version from the git tag via hatch-vcs (writing a generated `aai_cli/_version.py`), eliminating the hand-synced version strings in `pyproject.toml` + `aai_cli/__init__.py` so releasing collapses to pushing a tag.
+
+**Architecture:** `hatchling` + `hatch-vcs` resolves the version from git at build time and writes it into a gitignored `aai_cli/_version.py` via the `vcs` build hook. `aai_cli/__init__.py` imports `__version__` from that file. The Homebrew formula (which builds from a git-less source tarball) passes the version through `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_AAI_CLI`. Strict-gate tools are told about the generated file via config only.
+
+**Tech Stack:** hatchling, hatch-vcs (→ setuptools-scm), uv, ruff, mypy, pyright, vulture, import-linter, pytest. Spec: `docs/superpowers/specs/2026-06-12-hatch-vcs-versioning-design.md`.
+
+---
+
+## Commit discipline (read first)
+
+This repo's PreToolUse hook blocks `git commit` unless `./scripts/check.sh` passed for the exact working tree (see CLAUDE.md + memory). The full gate is slow, so:
+
+- End each task with a **WIP commit** prefixed `AAI_ALLOW_COMMIT=1` (allowed without a gate pass).
+- The **final task** runs the full gate to green; that records the gate marker for the final tree. Squash the WIP commits before opening the PR if you want a clean history.
+- Targeted verification commands in each task (single `pytest`, one tool) are how you confirm a step — not the full gate.
+
+## File Structure
+
+- `pyproject.toml` — **modify**: build-system requires, dynamic version, `[tool.hatch.version]` + `[tool.hatch.build.hooks.vcs]`, ruff + vulture excludes for the generated file.
+- `aai_cli/_version.py` — **generated** (gitignored, never committed).
+- `aai_cli/__init__.py` — **modify**: literal → `from aai_cli._version import __version__`.
+- `.gitignore` — **modify**: ignore the generated file.
+- `Formula/assembly.rb` — **modify**: pretend-version env in `def install`.
+- `scripts/bump_patch.sh` — **delete**.
+- `scripts/cut_release.sh` — **modify**: derive version from tags, not files.
+- `CLAUDE.md` — **modify**: release/versioning docs.
+- `.importlinter`, `[tool.mypy]`, `[tool.pyright]` — **verify only** (edit only if a tool rejects the generated file).
+
+---
+
+## Task 1: Convert to dynamic VCS version + verify `uv lock --check` (the gating spike)
+
+This task is the spike from the spec's build sequence: it proves `uv lock --check` tolerates a VCS-derived version. **If Step 6 fails, stop and apply the fallback before any later task.**
+
+**Files:**
+- Modify: `pyproject.toml:1-3` (build-system), `pyproject.toml:7` (version)
+- Modify: `pyproject.toml` (add hatch version sections near `[tool.hatch.build.targets.wheel]`, ~line 114)
+- Modify: `.gitignore`
+- Generated: `aai_cli/_version.py`
+
+- [ ] **Step 1: Add hatch-vcs to the build requirements**
+
+In `pyproject.toml`, change:
+```toml
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+```
+to:
+```toml
+[build-system]
+requires = ["hatchling", "hatch-vcs"]
+build-backend = "hatchling.build"
+```
+
+- [ ] **Step 2: Make the project version dynamic**
+
+In `pyproject.toml` `[project]`, replace the line:
+```toml
+version = "0.1.4"
+```
+with:
+```toml
+dynamic = ["version"]
+```
+
+- [ ] **Step 3: Add the hatch-vcs version config + version-file hook**
+
+In `pyproject.toml`, immediately above the existing `[tool.hatch.build.targets.wheel]` section (~line 114), add:
+```toml
+[tool.hatch.version]
+source = "vcs"
+
+[tool.hatch.build.hooks.vcs]
+version-file = "aai_cli/_version.py"
+```
+Do **not** add `raw-options`/`local_scheme` — the default scheme (dev builds → `0.1.5.dev3+g`) is intended.
+
+- [ ] **Step 4: Gitignore the generated file**
+
+Append to `.gitignore` (after the `poetry.lock` block, near the other tooling entries):
+```gitignore
+# Generated by hatch-vcs at build time (version derived from the git tag)
+aai_cli/_version.py
+```
+
+- [ ] **Step 5: Sync so the build hook generates the file**
+
+Run: `uv sync`
+Expected: succeeds; `aai_cli/_version.py` now exists.
+Verify: `test -f aai_cli/_version.py && grep -c "__version__" aai_cli/_version.py`
+Expected: prints a non-zero count (the file defines `__version__`).
+Also confirm it is ignored: `git status --porcelain aai_cli/_version.py`
+Expected: **no output** (gitignored, not shown as untracked).
+
+- [ ] **Step 6: SPIKE — verify `uv lock --check` stays green**
+
+Run: `uv lock && uv lock --check`
+Expected: both succeed (exit 0).
+
+Then prove it survives a commit *past* the tag (the dev-version case):
+```bash
+git add -A && AAI_ALLOW_COMMIT=1 git commit -q -m "wip: dynamic vcs version"
+uv lock --check
+```
+Expected: exit 0 even though HEAD is now one commit past `v0.1.4`.
+
+**If `uv lock --check` fails** (the recorded project version drifts): apply the spec fallback — export `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_AAI_CLI` in the dev environment (e.g. document it in `scripts/check.sh` / a `.env`), re-run `uv lock`, and re-verify. If no clean fallback works, **halt and report** — the approach needs revisiting. Otherwise continue.
+
+- [ ] **Step 7: Commit**
+
+(Already committed in Step 6 if you ran the dev-version check; otherwise:)
+```bash
+git add pyproject.toml .gitignore
+AAI_ALLOW_COMMIT=1 git commit -m "build: derive version from git via hatch-vcs"
+```
+
+---
+
+## Task 2: Teach the strict-gate tools about the generated file
+
+**Files:**
+- Modify: `pyproject.toml` `[tool.ruff]` (~line 191) and `[tool.vulture]` (~line 259)
+- Verify only: `.importlinter`, `[tool.mypy]`, `[tool.pyright]`
+
+- [ ] **Step 1: Exclude the generated file from ruff**
+
+In `pyproject.toml`, under `[tool.ruff]` (after `target-version = "py312"`, ~line 193), add:
+```toml
+# hatch-vcs writes this at build time; it is not linted or formatted to project style.
+extend-exclude = ["aai_cli/_version.py"]
+```
+
+- [ ] **Step 2: Verify ruff passes**
+
+Run: `uv run ruff check . && uv run ruff format --check .`
+Expected: both pass (no complaints about `aai_cli/_version.py`).
+
+- [ ] **Step 3: Exclude the generated file from vulture**
+
+In `pyproject.toml`, under `[tool.vulture]`, add an `exclude` key (the template defines unused exports `version`, `version_tuple`, `__version_tuple__`; excluding the path avoids whitelisting the common name `version` globally):
+```toml
+exclude = ["aai_cli/_version.py"]
+```
+
+- [ ] **Step 4: Verify vulture passes**
+
+Run: `uv run vulture`
+Expected: passes with no report for `aai_cli/_version.py`.
+
+- [ ] **Step 5: Verify the type checkers and import-linter accept the generated file**
+
+Run: `uv run mypy && uv run pyright && uv run lint-imports`
+Expected: all pass. The generated `_version.py` is present (from Task 1 Step 5), typed by the setuptools-scm template, and import-free.
+
+**If mypy/pyright objects:** add a config-level override (e.g. a `[[tool.mypy.overrides]] module = "aai_cli._version"` with `ignore_errors = true`, or a pyright path entry) — **do not** exclude it from the import graph (the `__init__.py` import must still resolve) and **do not** add an inline `# type: ignore`.
+**If `lint-imports` objects:** add `aai_cli._version` to the relevant contract's `ignore_imports`/`modules` in `.importlinter`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add pyproject.toml .importlinter 2>/dev/null; git add pyproject.toml
+AAI_ALLOW_COMMIT=1 git commit -m "build: exclude generated _version.py from lint/dead-code gates"
+```
+
+---
+
+## Task 3: Read `__version__` from the generated file
+
+**Files:**
+- Modify: `aai_cli/__init__.py`
+- Test: existing `tests/test_smoke.py` (no new test — single branchless import)
+
+- [ ] **Step 1: Confirm the current behavior before changing it**
+
+Run: `uv run assembly --version`
+Expected: prints a version (currently `0.1.4`, or a dev version after Task 1's commit, e.g. `0.1.4.devN+g` — either is fine; note what it prints).
+
+- [ ] **Step 2: Replace the literal with the generated import**
+
+Replace the entire contents of `aai_cli/__init__.py` (currently `__version__ = "0.1.4"`) with:
+```python
+from aai_cli._version import __version__
+
+__all__ = ["__version__"]
+```
+
+- [ ] **Step 3: Verify the CLI still reports a version**
+
+Run: `uv run assembly --version`
+Expected: prints the same version string as Step 1 (now sourced from `_version.py`).
+
+- [ ] **Step 4: Verify the version test suite stays green**
+
+Run: `uv run pytest tests/test_smoke.py tests/test_telemetry.py tests/test_update_check.py tests/test_main_module.py -q`
+Expected: PASS. These read `__version__` dynamically, so they track whatever the generated file holds.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add aai_cli/__init__.py
+AAI_ALLOW_COMMIT=1 git commit -m "feat: source __version__ from hatch-vcs _version.py"
+```
+
+---
+
+## Task 4: Make the Homebrew formula pass the version through (no git in the tarball)
+
+**Files:**
+- Modify: `Formula/assembly.rb` (the `def install` method)
+
+- [ ] **Step 1: Set the pretend-version before installing**
+
+In `Formula/assembly.rb`, replace:
+```ruby
+def install
+ virtualenv_install_with_resources
+end
+```
+with:
+```ruby
+def install
+ # The GitHub source tarball has no .git, so hatch-vcs cannot derive the
+ # version. Feed it the formula's tag version (hatch-vcs delegates to
+ # setuptools-scm, which reads this env var); the build hook then writes the
+ # correct version into the installed aai_cli/_version.py.
+ ENV["SETUPTOOLS_SCM_PRETEND_VERSION_FOR_AAI_CLI"] = version.to_s
+ virtualenv_install_with_resources
+end
+```
+
+- [ ] **Step 2: Verify the pretend-version env var name is honored**
+
+The dist name `aai-cli` normalizes (uppercase, non-alphanumerics → `_`) to `AAI_CLI`, so the var is `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_AAI_CLI`. Confirm hatch-vcs honors it by asking the build backend for the version with the env var set:
+```bash
+SETUPTOOLS_SCM_PRETEND_VERSION_FOR_AAI_CLI=9.9.9 uv run python -m hatchling version
+```
+Expected: prints `9.9.9`. If it prints a git-derived version instead, the var name is wrong — retry with the generic `SETUPTOOLS_SCM_PRETEND_VERSION=9.9.9`, and if that works, use the generic form in the formula and note why.
+
+> Note: the formula's bottle build runs only in `release.yml` on macOS CI; this local check confirms the env-var contract without a full `brew install`. The real assertion (`assembly --version` == tag) runs in the release workflow's `test do`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add Formula/assembly.rb
+AAI_ALLOW_COMMIT=1 git commit -m "build(brew): pass tag version to hatch-vcs (tarball has no .git)"
+```
+
+---
+
+## Task 5: Replace the bump/release scripts
+
+**Files:**
+- Delete: `scripts/bump_patch.sh`
+- Modify: `scripts/cut_release.sh`
+
+- [ ] **Step 1: Delete the now-obsolete bump script**
+
+Run: `git rm scripts/bump_patch.sh`
+Expected: file removed (there is no version string to bump anymore).
+
+- [ ] **Step 2: Rewrite `cut_release.sh` to derive the version from tags**
+
+Replace the version-resolution section of `scripts/cut_release.sh`. Specifically, remove the block that reads `version` from `pyproject.toml` and the `init_version` mismatch check, and replace it with tag-derived logic. The new top-of-script (after the arg-parsing `for`/`info`/`err` helpers and the `cd "$root"`) becomes:
+
+```sh
+# --- Resolve the version to tag --------------------------------------------
+# With hatch-vcs the git tag IS the version; there is no file to read. Default
+# to the next patch above the latest vX.Y.Z tag; an explicit arg overrides.
+latest="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)"
+[ -n "$latest" ] || err "no existing vX.Y.Z tag found; pass an explicit version."
+
+if [ -n "${EXPLICIT_VERSION:-}" ]; then
+ version="$EXPLICIT_VERSION"
+else
+ base="${latest#v}"
+ major="$(echo "$base" | cut -d. -f1)"
+ minor="$(echo "$base" | cut -d. -f2)"
+ patch="$(echo "$base" | cut -d. -f3)"
+ version="${major}.${minor}.$((patch + 1))"
+fi
+echo "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ||
+ err "version '$version' is not a plain MAJOR.MINOR.PATCH triple."
+tag="v${version}"
+info "Latest tag ${latest}; releasing ${tag}."
+```
+
+In the arg-parsing `for arg in "$@"` loop, add a case that captures a bare `X.Y.Z` argument into `EXPLICIT_VERSION` (alongside the existing `-y`/`-n`/`-h`). Replace the catch-all `*)` error case with:
+```sh
+ [0-9]*.[0-9]*.[0-9]*) EXPLICIT_VERSION="$arg" ;;
+ *)
+ printf 'unknown argument: %s (try --help)\n' "$arg" >&2
+ exit 2
+ ;;
+```
+
+Update the script's header comment block to document: no version file is read; default is next-patch-from-latest-tag; `cut_release.sh 0.2.0` overrides; bumping/merging a version PR is no longer a prerequisite. Keep the existing safety gates (on `main`, clean tree, in sync with `origin/main`, tag-not-exists) and the `-n`/`-y` behavior unchanged.
+
+- [ ] **Step 3: Verify shellcheck is clean**
+
+Run: `shellcheck scripts/cut_release.sh`
+Expected: no findings.
+
+- [ ] **Step 4: Verify the dry run resolves a sane tag**
+
+Run: `./scripts/cut_release.sh -n`
+Expected: prints `Latest tag v0.1.4; releasing v0.1.5.` then the dry-run "all checks passed" line, and does **not** tag or push. (It may fail the on-`main` / in-sync gate while on the feature branch — that is correct; confirm the failure is a *safety gate*, not a version-resolution error. To check resolution alone on the branch, read the printed "releasing vX.Y.Z" line.)
+
+Run the explicit-version path too: `./scripts/cut_release.sh -n 0.2.0`
+Expected: prints `releasing v0.2.0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/cut_release.sh
+AAI_ALLOW_COMMIT=1 git commit -m "build: cut_release derives version from tags; drop bump_patch.sh"
+```
+
+---
+
+## Task 6: Update CLAUDE.md
+
+**Files:**
+- Modify: `CLAUDE.md` (the "Naming & packaging gotchas" release paragraph)
+
+- [ ] **Step 1: Locate the release paragraph**
+
+Run: `grep -n "bump_patch\|cut_release\|lock-step\|tag-triggered" CLAUDE.md`
+Expected: finds the "Releasing is tag-triggered" paragraph referencing `bump_patch.sh` + `cut_release.sh` + the dual-file lock-step.
+
+- [ ] **Step 2: Rewrite that paragraph**
+
+Edit the release paragraph so it reads (preserving surrounding context/links):
+- The version is **derived from the git tag** by hatch-vcs and written to a gitignored `aai_cli/_version.py` at build time; there is no version string in `pyproject.toml`/`aai_cli/__init__.py` to keep in sync, and `bump_patch.sh` no longer exists.
+- Releasing is still tag-triggered: run `scripts/cut_release.sh` from a clean, in-sync `main` (no arg → next patch above the latest tag; `cut_release.sh X.Y.Z` → explicit). It tags + pushes, which fires `release.yml`.
+- The Homebrew formula builds from a git-less source tarball, so `Formula/assembly.rb`'s `def install` sets `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_AAI_CLI` to feed the tag version to the build.
+
+- [ ] **Step 3: Verify markdownlint is clean**
+
+Run: `markdownlint CLAUDE.md`
+Expected: no findings.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add CLAUDE.md
+AAI_ALLOW_COMMIT=1 git commit -m "docs: document tag-derived versioning in CLAUDE.md"
+```
+
+---
+
+## Task 7: Full gate + finalize
+
+**Files:** none (verification + final state)
+
+- [ ] **Step 1: Run the authoritative gate to green**
+
+Run: `./scripts/check.sh`
+Expected: ends with `All checks passed.` This regenerates `_version.py` via `uv sync`, runs every tool, the coverage/mutation/patch gates, the `uv build` + `twine check --strict` tail, and records the gate marker for the final tree.
+
+**If the diff gates flag the touched lines:** the changed config/script lines are mostly non-executable; for any mutation-surviving line in `cut_release.sh`/`__init__.py` that genuinely can't be asserted, confirm an existing test covers the behavior before considering a `# pragma: no mutate`. Do not add escape hatches to silence the "no new escape hatches" gate.
+
+- [ ] **Step 2: Confirm the generated file is absent from git**
+
+Run: `git status --porcelain && git ls-files aai_cli/_version.py`
+Expected: clean tree; `git ls-files` prints **nothing** (the generated file is not tracked).
+
+- [ ] **Step 3: (Optional) squash WIP commits**
+
+If a clean history is wanted before the PR:
+```bash
+git rebase -i origin/main # squash the AAI_ALLOW_COMMIT wip commits into one
+```
+Then re-run `./scripts/check.sh` so the final tree carries a fresh gate marker, and create the PR per the repo's normal flow.
+
+---
+
+## Self-Review (completed during planning)
+
+- **Spec coverage:** version source (T1), runtime read (T3), gate accommodations (T2), Homebrew pretend-version (T4), script changes (T5), CLAUDE.md (T6), `uv lock --check` spike (T1 Step 6), full gate (T7) — all spec sections map to a task.
+- **Placeholders:** none — every edit shows exact before/after text and every verification gives a command + expected output.
+- **Consistency:** the env var `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_AAI_CLI` is used identically in T4 and T6; `aai_cli/_version.py` is the single generated path throughout; the default (no `raw-options`) local-version scheme is stated in T1 and reflected in T3's expected dev-version output.
diff --git a/docs/superpowers/specs/2026-06-12-hatch-vcs-versioning-design.md b/docs/superpowers/specs/2026-06-12-hatch-vcs-versioning-design.md
new file mode 100644
index 00000000..ce00ac6b
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-12-hatch-vcs-versioning-design.md
@@ -0,0 +1,177 @@
+# Tag-derived versioning via hatch-vcs
+
+**Date:** 2026-06-12
+**Status:** Approved — pending spec review
+
+## Goal
+
+Make releases use idiomatic hatchling: derive the package version from the git
+tag via `hatch-vcs` instead of hand-syncing a literal version string across two
+files. Releasing collapses to "tag `main`"; there is no version string to bump.
+
+## Motivation
+
+Today the version lives in **two** hand-synced places — `pyproject.toml`
+(`version = "0.1.4"`) and `aai_cli/__init__.py` (`__version__ = "0.1.4"`) — kept
+in lock-step by `scripts/bump_patch.sh`, which also refreshes `uv.lock`.
+`scripts/cut_release.sh` then tags whatever `pyproject.toml` holds. The dual-file
+sync is pure ceremony: the tag is already the real source of release truth.
+
+## Constraints (must not break)
+
+1. **The Homebrew bottle builds from a GitHub source tarball**
+ (`archive/refs/tags/vX.Y.Z.tar.gz`) via `virtualenv_install_with_resources`.
+ That tarball has **no `.git` directory**, so deriving the version from git at
+ *install* time yields nothing.
+2. **The formula's `test do` asserts `assembly --version` == the tag version**
+ (`assert_match version.to_s, shell_output("#{bin}/assembly --version")`), so
+ the installed build must report the real version even without git.
+3. **The gate runs `uv lock --check`** — the project's own version is recorded in
+ `uv.lock`, so a per-commit-drifting version could make the gate perpetually red.
+4. **`__version__` is read at runtime in five modules** — `main.py` (`--version`
+ callback), `telemetry.py` (ddtags + payload), `output.py` (banner), `init.py`
+ (header), `update_check.py` (user-agent + newer-than check). All import
+ `from aai_cli import __version__`; the symbol must keep working.
+
+Verified non-issues:
+
+- **No syrupy snapshot pins the literal version** (`grep -r 0.1.4
+ tests/__snapshots__/` is empty), so dynamic dev versions (`0.1.5.devN`) will not
+ destabilize snapshot tests. The banner renders `__version__` but is not snapshotted.
+- **No test hardcodes `"0.1.4"`** — version tests compare against `__version__`
+ itself, so dynamic versioning keeps them green.
+- A clean `main` checkout sitting on the latest tag computes exactly that tag
+ (e.g. `0.1.4`, no dev suffix); dev suffixes appear only on commits past a tag.
+
+## Design
+
+### Version source — `pyproject.toml`
+
+- `[build-system].requires = ["hatchling", "hatch-vcs"]`
+- `[project]`: remove `version = "0.1.4"`; add `dynamic = ["version"]`
+- Add:
+ ```toml
+ [tool.hatch.version]
+ source = "vcs"
+
+ [tool.hatch.build.hooks.vcs]
+ version-file = "aai_cli/_version.py"
+ ```
+ The `vcs` build hook writes the resolved version into `aai_cli/_version.py` at
+ build time — including the editable build that `uv sync` performs, so the file
+ exists on disk after the first sync. **Take the default local-version scheme**
+ (no `raw-options`): dev builds report `0.1.5.dev3+g1a2b3c4`, and the embedded
+ commit hash identifies exactly which dev build a `--version` / telemetry tag
+ came from. Real releases sit on a tag, so they report a clean `0.1.5`.
+
+### Runtime `__version__` — `aai_cli/__init__.py`
+
+Replace the literal with a direct import from the generated file:
+
+```python
+from aai_cli._version import __version__
+```
+
+`_version.py` is generated by the build hook (never committed — see gitignore
+below) and is present after `uv sync` / any build, so the import resolves for the
+uv editable install, the built wheel, and the Homebrew venv alike. All five
+runtime consumers and every existing test continue to read `__version__`
+unchanged. No fallback branch is needed: every gate step and test runs after
+`uv sync`, so the module always exists.
+
+### Gate-config accommodations for the generated file
+
+`aai_cli/_version.py` is generated, gitignored, and materializes only after a
+build, so the gate tools that walk the working tree must be told about it — all
+via **config**, never inline `# type: ignore` / `# noqa` (which the
+"no new escape hatches" gate would reject):
+
+- **`.gitignore`** — add `aai_cli/_version.py`.
+- **ruff** (lint + format) — exclude the generated file; the setuptools-scm
+ template is neither linted nor formatted to project style.
+- **vulture** — whitelist/exclude it (`version`, `version_tuple`,
+ `__version_tuple__` are unused exports).
+- **pyright / mypy** — do **not** exclude it: the import in `__init__.py` must
+ resolve, and the generated template is typed and passes strict mode. Verify
+ during implementation; only add a config-level override if strict mode objects.
+- **import-linter** — `_version.py` is an import-free leaf; confirm the contracts
+ don't require it to sit in a named layer (add to `ignore_imports` only if so).
+
+### Homebrew formula — `Formula/assembly.rb`
+
+The GitHub tarball has no `.git`, so `def install` sets the setuptools-scm
+pretend-version (hatch-vcs delegates to setuptools-scm) from the formula's parsed
+`version` before installing:
+
+```ruby
+def install
+ ENV["SETUPTOOLS_SCM_PRETEND_VERSION_FOR_AAI_CLI"] = version.to_s
+ virtualenv_install_with_resources
+end
+```
+
+With the pretend-version set, the build hook writes the tag version into the
+installed `aai_cli/_version.py`, so `assembly --version` reports it and the
+`test do` assertion passes. The exact env var name
+(`SETUPTOOLS_SCM_PRETEND_VERSION_FOR_AAI_CLI`, normalized dist name) is verified
+during implementation.
+
+`.github/workflows/release.yml` needs **no change** — it operates on tags + the
+formula, not on a version string.
+
+### Scripts
+
+- **Delete `scripts/bump_patch.sh`** — there is no version string to bump.
+- **Rewrite `scripts/cut_release.sh`** to take the version from the tag rather
+ than reading files:
+ - No arg → compute the next patch from the latest `vX.Y.Z` tag (preserves the
+ current `bump_patch.sh` patch-bump ergonomics).
+ - Explicit `cut_release.sh 0.2.0` → tag that version.
+ - Keep all existing safety gates: must be on `main`, clean working tree, in sync
+ with `origin/main`, tag must not already exist locally or on origin.
+ - Keep `-n/--dry-run` and `-y/--yes`.
+
+### Tests
+
+- No new version-logic test is needed: `__init__.py` is now a single
+ unconditional import (`from aai_cli._version import __version__`) with no
+ branch, and every test imports `aai_cli`, so the line is covered with nothing
+ for the mutation gate to leave surviving.
+- Existing version tests (`test_smoke.py`, `test_telemetry.py`,
+ `test_update_check.py`, `test_main_module.py`) stay green unchanged — they read
+ `__version__` dynamically, not a literal.
+
+### Docs — `CLAUDE.md`
+
+Update the "Naming & packaging gotchas" + release paragraphs: drop the dual-file
+lock-step description, document tag-derived versioning, the deleted
+`bump_patch.sh`, the new `cut_release.sh` behavior, and the formula's
+pretend-version requirement.
+
+## Build sequence
+
+1. **Spike `uv lock --check` with the dynamic version.** Convert
+ `pyproject.toml` to dynamic VCS version, run `uv lock` then `uv lock --check`
+ on a dev commit (past the tag). Confirm the recorded project version does not
+ drift the check red.
+ - **Fallback if it drifts:** pin the project version for lock/dev via
+ `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_AAI_CLI` in the dev environment, or
+ document a `uv lock` refresh step. This step **gates everything else** — if
+ the fallback is unacceptable, revisit the approach before proceeding.
+2. `pyproject.toml` build-system + dynamic version + `[tool.hatch.version]` +
+ `[tool.hatch.build.hooks.vcs]` version-file.
+3. Gitignore `aai_cli/_version.py`; add the ruff + vulture excludes; run `uv sync`
+ so the file generates, then confirm pyright/mypy/import-linter accept it.
+4. `aai_cli/__init__.py` → `from aai_cli._version import __version__`.
+5. `Formula/assembly.rb` pretend-version in `def install`.
+6. Delete `bump_patch.sh`; rewrite `cut_release.sh`.
+7. Update `CLAUDE.md`.
+8. Run the full gate (`./scripts/check.sh`) to green.
+
+## Out of scope
+
+- Publishing to PyPI (the `assemblyai-cli` / `aai-cli` name is squatted; releases
+ are Homebrew-only).
+- Changing the release CI workflow structure (bottle build, GH release, formula PR).
+- Conventional-commits / changelog automation (`python-semantic-release`,
+ `release-please`) — a separate future decision.
diff --git a/pyproject.toml b/pyproject.toml
index 9074834a..11ae0f5c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,10 +1,10 @@
[build-system]
-requires = ["hatchling"]
+requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "aai-cli"
-version = "0.1.4"
+dynamic = ["version"]
description = "Command-line interface for AssemblyAI"
readme = "README.md"
license = "MIT"
@@ -111,6 +111,12 @@ dev = [
# here so it's explicit and stable).
default-groups = ["dev"]
+[tool.hatch.version]
+source = "vcs"
+
+[tool.hatch.build.hooks.vcs]
+version-file = "aai_cli/_version.py"
+
[tool.hatch.build.targets.wheel]
packages = ["aai_cli"]
# Force-include the committed template files (incl. renamed dotfiles); the `**` glob
@@ -191,6 +197,8 @@ venv = ".venv"
[tool.ruff]
line-length = 100
target-version = "py312"
+# hatch-vcs writes this at build time; it is not linted or formatted to project style.
+extend-exclude = ["aai_cli/_version.py"]
[tool.ruff.lint]
# PGH/ERA/TRY/TD/FIX close the "make the error go away" escape hatches a coding agent
@@ -258,6 +266,7 @@ max-statements = 40
[tool.vulture]
paths = ["aai_cli", "tests"]
+exclude = ["aai_cli/_version.py"]
min_confidence = 90
ignore_decorators = ["@app.command", "@app.callback"]
ignore_names = ["app", "capture_output", "download", "healthy", "ist", "memory_keyring", "org",
diff --git a/scripts/bump_patch.sh b/scripts/bump_patch.sh
deleted file mode 100755
index 0ffc8f56..00000000
--- a/scripts/bump_patch.sh
+++ /dev/null
@@ -1,79 +0,0 @@
-#!/bin/sh
-# Bump the AssemblyAI CLI version by one patch increment (X.Y.Z -> X.Y.(Z+1)),
-# updating the two files that must stay in lock-step: pyproject.toml and
-# aai_cli/__init__.py. It does NOT commit, tag, or push.
-#
-# ./scripts/bump_patch.sh # bump and write both files
-# ./scripts/bump_patch.sh -n # dry run: print the new version, write nothing
-#
-# Typical flow: run this on a branch, commit the change, open + merge the PR,
-# then run ./scripts/cut_release.sh on main to tag the new version.
-set -eu
-
-DRY_RUN=0
-for arg in "$@"; do
- case "$arg" in
- -n | --dry-run) DRY_RUN=1 ;;
- -h | --help)
- sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'
- exit 0
- ;;
- *)
- printf 'unknown argument: %s (try --help)\n' "$arg" >&2
- exit 2
- ;;
- esac
-done
-
-info() { printf '\033[1;34m==>\033[0m %s\n' "$1"; }
-err() {
- printf '\033[1;31merror:\033[0m %s\n' "$1" >&2
- exit 1
-}
-
-# Run from the repo root so the relative paths below resolve regardless of CWD.
-root="$(git rev-parse --show-toplevel)" || err "not inside a git repository."
-cd "$root"
-
-# --- Single source of truth: the version in pyproject.toml -----------------
-version="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/^version = "([^"]+)".*/\1/')"
-[ -n "$version" ] || err "could not read version from pyproject.toml."
-
-# __version__ must already match, or the two files would diverge on the bump.
-init_version="$(grep -m1 '__version__' aai_cli/__init__.py | sed -E 's/.*"([^"]+)".*/\1/')"
-[ "$init_version" = "$version" ] ||
- err "version mismatch: pyproject.toml=$version but aai_cli/__init__.py=$init_version."
-
-# --- Compute the patch bump (X.Y.Z -> X.Y.(Z+1)) ---------------------------
-echo "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ||
- err "version '$version' is not a plain MAJOR.MINOR.PATCH triple; bump it by hand."
-
-major="$(echo "$version" | cut -d. -f1)"
-minor="$(echo "$version" | cut -d. -f2)"
-patch="$(echo "$version" | cut -d. -f3)"
-new_version="${major}.${minor}.$((patch + 1))"
-info "Bumping ${version} -> ${new_version}."
-
-if [ "$DRY_RUN" -eq 1 ]; then
- info "Dry run: not writing any files."
- exit 0
-fi
-
-# --- Rewrite both files (portable in-place edit via temp file) -------------
-replace_in_file() {
- # $1 file, $2 sed expression
- tmp="$(mktemp)"
- sed -E "$2" "$1" >"$tmp"
- mv "$tmp" "$1"
-}
-
-replace_in_file pyproject.toml "s/^version = \"${version}\"/version = \"${new_version}\"/"
-replace_in_file aai_cli/__init__.py "s/__version__ = \"${version}\"/__version__ = \"${new_version}\"/"
-
-# Refresh uv.lock so the project version in the lockfile stays in sync; the gate
-# runs `uv lock --check` and would otherwise fail on the stale lock.
-info "Refreshing uv.lock."
-uv lock || err "uv lock failed."
-
-info "Updated pyproject.toml, aai_cli/__init__.py, and uv.lock to ${new_version}."
-info "Next: commit the change, open + merge the PR, then run ./scripts/cut_release.sh on main."
diff --git a/scripts/check.sh b/scripts/check.sh
index a51be1cd..b312a7d5 100755
--- a/scripts/check.sh
+++ b/scripts/check.sh
@@ -125,7 +125,7 @@ if command -v shellcheck >/dev/null 2>&1; then
# -x + --source-path=. let it follow the hook's `. scripts/gate_tool_pins.sh`
# (paths resolve from the repo root, where this script always runs).
shellcheck -x --source-path=. scripts/check.sh scripts/docker_build_check.sh \
- scripts/cut_release.sh scripts/bump_patch.sh scripts/gate_tool_pins.sh \
+ scripts/cut_release.sh scripts/gate_tool_pins.sh \
.claude/hooks/session-start.sh .claude/hooks/require-gate-before-commit.sh
else
echo " shellcheck not found; skipping (CI runs it)"
diff --git a/scripts/cut_release.sh b/scripts/cut_release.sh
index 1181d726..54b14199 100755
--- a/scripts/cut_release.sh
+++ b/scripts/cut_release.sh
@@ -1,14 +1,16 @@
#!/bin/sh
-# Cut an AssemblyAI CLI release: tag the version from pyproject.toml and push the
-# tag, which triggers .github/workflows/release.yml (builds the arm64 bottle,
-# creates the GitHub Release, opens the formula PR).
+# Cut an AssemblyAI CLI release: tag the version and push the tag, which triggers
+# .github/workflows/release.yml (builds the arm64 bottle, creates the GitHub
+# Release, opens the formula PR).
#
-# ./scripts/cut_release.sh # verify, confirm, then tag + push
+# With hatch-vcs the git tag IS the version — there is no version file to bump
+# or version-bump PR to merge first. By default the script tags the next patch
+# above the latest vX.Y.Z tag; pass an explicit version to override.
+#
+# ./scripts/cut_release.sh # next patch above latest tag; confirm, tag + push
+# ./scripts/cut_release.sh 0.2.0 # tag an explicit version instead
# ./scripts/cut_release.sh --yes # skip the interactive confirmation
# ./scripts/cut_release.sh -n # dry run: verify only, don't tag or push
-#
-# Bump the version (pyproject.toml + aai_cli/__init__.py) and merge that PR
-# BEFORE running this — the script tags whatever version main currently holds.
set -eu
ASSUME_YES=0
@@ -18,9 +20,10 @@ for arg in "$@"; do
-y | --yes) ASSUME_YES=1 ;;
-n | --dry-run) DRY_RUN=1 ;;
-h | --help)
- sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'
+ sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
+ [0-9]*.[0-9]*.[0-9]*) EXPLICIT_VERSION="$arg" ;;
*)
printf 'unknown argument: %s (try --help)\n' "$arg" >&2
exit 2
@@ -38,15 +41,30 @@ err() {
root="$(git rev-parse --show-toplevel)" || err "not inside a git repository."
cd "$root"
-# --- Single source of truth: the version in pyproject.toml -----------------
-version="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/^version = "([^"]+)".*/\1/')"
-[ -n "$version" ] || err "could not read version from pyproject.toml."
+# --- Resolve the version to tag --------------------------------------------
+# With hatch-vcs the git tag IS the version; there is no file to read. Default
+# to the next patch above the latest vX.Y.Z tag; an explicit arg overrides.
+latest="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)"
+
+if [ -n "${EXPLICIT_VERSION:-}" ]; then
+ version="$EXPLICIT_VERSION"
+else
+ # Auto-bump needs a tag to bump from; an explicit version does not.
+ [ -n "$latest" ] || err "no existing vX.Y.Z tag found; pass an explicit version."
+ base="${latest#v}"
+ major="$(echo "$base" | cut -d. -f1)"
+ minor="$(echo "$base" | cut -d. -f2)"
+ patch="$(echo "$base" | cut -d. -f3)"
+ version="${major}.${minor}.$((patch + 1))"
+fi
+echo "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ||
+ err "version '$version' is not a plain MAJOR.MINOR.PATCH triple."
tag="v${version}"
-
-# __version__ must match (the `version` command and tests read it).
-init_version="$(grep -m1 '__version__' aai_cli/__init__.py | sed -E 's/.*"([^"]+)".*/\1/')"
-[ "$init_version" = "$version" ] ||
- err "version mismatch: pyproject.toml=$version but aai_cli/__init__.py=$init_version."
+if [ -n "$latest" ]; then
+ info "Latest tag ${latest}; releasing ${tag}."
+else
+ info "No prior tags; releasing ${tag}."
+fi
# --- Safety gates ----------------------------------------------------------
[ -f .github/workflows/release.yml ] ||
diff --git a/tests/test_onboard_command.py b/tests/test_onboard_command.py
index dca3d25b..227e893d 100644
--- a/tests/test_onboard_command.py
+++ b/tests/test_onboard_command.py
@@ -203,20 +203,23 @@ def test_bare_aai_with_key_shows_help_no_offer(monkeypatch: pytest.MonkeyPatch)
def test_bare_aai_prints_welcome_header(monkeypatch: pytest.MonkeyPatch) -> None:
- # The welcome screen leads with the version + tagline header line.
+ # The welcome screen leads with the emoji + product + version header line.
+ from aai_cli import __version__
+
monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test")
result = CliRunner().invoke(app, [])
assert result.exit_code == 0, result.output
- assert "AssemblyAI from your terminal" in result.output
+ assert "🎙️ AssemblyAI CLI" in result.output
+ assert __version__ in result.output
def test_bare_aai_quiet_suppresses_banner(monkeypatch: pytest.MonkeyPatch) -> None:
# `--quiet` drops the decorative header but still prints help. A mutant that
- # ignores `quiet` (always banners) would leave the tagline in the output.
+ # ignores `quiet` (always banners) would leave the brand header in the output.
monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test")
result = CliRunner().invoke(app, ["--quiet"])
assert result.exit_code == 0, result.output
- assert "AssemblyAI from your terminal" not in result.output
+ assert "🎙️ AssemblyAI CLI" not in result.output
assert "Usage" in result.output or "Commands" in result.output
diff --git a/uv.lock b/uv.lock
index dc3e8202..04a85fe9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -18,7 +18,6 @@ resolution-markers = [
[[package]]
name = "aai-cli"
-version = "0.1.4"
source = { editable = "." }
dependencies = [
{ name = "assemblyai" },