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. +

+ The assembly CLI welcome screen, listing command groups for transcription, streaming, voice agents, app scaffolding, and account management +

+ ## 🚀 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" },