diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md
new file mode 100644
index 000000000..a91c7713b
--- /dev/null
+++ b/.claude/skills/release/SKILL.md
@@ -0,0 +1,54 @@
+---
+name: release
+description: Cut a Kosli CLI release - suggest the next semver tag from the changes since the last tag, draft the release notes with the user, then tag and push via `make release`. Use when asked to "release", "cut a release", "tag a new version", or "draft release notes".
+---
+
+## Overview
+
+A release is an annotated tag on `main`. Its message becomes the top of the GitHub Release:
+`release.yml` reads it and hands it to GoReleaser, which appends the "What's Changed" list
+of merged PRs. This skill does the judgment work (version bump, notes) and leaves the
+mechanics to `make release tag=vX.Y.Z`, which creates the annotated tag from
+`dist/release_notes.md` and pushes it. `make release` is the only way the tag gets
+created; see the Releasing section of `CLAUDE.md` for why.
+
+## Steps
+
+**1. Check the starting point.** Run `git fetch origin --tags`. Confirm the current
+branch is `main`, it matches `origin/main`, and the working tree has no tracked changes.
+Find the base with `git describe --tags --abbrev=0`. If any check fails, stop and tell
+the user what to fix. Done when `main` == `origin/main` and the base tag is known.
+
+**2. Classify every change since the base.** List commits with
+`git log --oneline ..HEAD`, then read the diffs of the ones that touch `cmd/` or
+`internal/` (`git show -- cmd internal`). Only user-facing CLI changes count:
+commands, flags, output, defaults, error behaviour, and fixes a user would notice.
+Tests, `testdata/`, docs, `charts/`, `.github/`, `bin/`, scripts, the Makefile and
+dependency bumps without behaviour change do not. Done when every commit is marked
+user-facing or not, with a one-line reason.
+
+**3. Pick the bump.** Base it only on the user-facing changes:
+
+- **major**: a removed or renamed command or flag, or a changed default.
+- **minor**: a new command, subcommand, flag or feature.
+- **patch**: bug fixes, refactors, dependency updates, or no user-facing changes.
+
+Compute the next tag from the base (`v2.48.2` + minor -> `v2.49.0`) and confirm it does
+not exist yet with `git tag -l `. A pre-release tag (`v2.49.0-rc1`) only when the
+user asks for one.
+
+**4. Draft `dist/release_notes.md`.** Write user-facing CLI changes only, as concise
+bullets under `#` headings, using only the headings that have entries, in this order:
+`# Breaking changes`, `# New features`, `# Improvements`, `# Bug fixes`. Each bullet
+names the command or flag and what changed for the user. When there are no user-facing
+changes, the file is a single line with no heading: `No user-facing CLI changes.`
+Create `dist/` if needed and overwrite any file already there.
+
+**5. Review with the user.** Show the suggested tag, the reasoning for the bump (the
+user-facing changes that drove it), and the notes. Apply every edit they ask for to
+`dist/release_notes.md` and show the result again. Done when the user explicitly
+approves both the tag and the notes.
+
+**6. Tag and push.** Pushing the tag starts the release pipeline, so ask once more:
+"Create and push ``?". On a yes, run `make release tag=`. Then report the run
+with `gh run list --workflow release.yml --limit 1`.
diff --git a/CLAUDE.md b/CLAUDE.md
index b678f831d..107b88bba 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -91,14 +91,7 @@ All command tests in `cmd/kosli/` use the **testify suite** pattern:
**NEVER use `gh release create` to cut a release.** It creates a lightweight tag and a premature GitHub Release, both of which break the release pipeline (never-alone-trail fails, GoReleaser conflicts with the pre-existing release).
-The release process is documented in [release-guide.md](/release-guide.md). The correct way to release:
-
-```bash
-make release # Interactive: AI suggests version + changelog, you confirm
-make release tag=v2.x.y # Fallback: explicit tag, no AI
-```
-
-Both paths create an **annotated tag** and push it. The `release.yml` workflow then runs GoReleaser, which creates the GitHub Release at the correct point in the pipeline. Do not create tags or releases via any other method.
+The release process is documented in [release-guide.md](/release-guide.md). To cut a release, use the `release` skill (`.claude/skills/release/`): it drafts the version and notes with the user, then runs `make release tag=v2.x.y`. That make target is the only path that creates the tag: it makes an **annotated tag** carrying `dist/release_notes.md` and pushes it. The `release.yml` workflow then runs GoReleaser, which creates the GitHub Release at the correct point in the pipeline. Do not create tags or releases via any other method.
## Working Style: TDD
diff --git a/Makefile b/Makefile
index 53a1951d8..b13209489 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: help build clean clean-cache deps fmt lint vet docker ldflags ensure_golangci-lint check_dirty add_test_tag build_release ensure_network ensure_gotestsum test_setup test_integration test_integration_full test_integration_single licenses upgrade-deps helm-lint helm-docs docs release suggest-version-ai
+.PHONY: help build clean clean-cache deps fmt lint vet docker ldflags ensure_golangci-lint check_dirty add_test_tag build_release ensure_network ensure_gotestsum test_setup test_integration test_integration_full test_integration_single licenses upgrade-deps helm-lint helm-docs docs release
.DEFAULT: help
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
@@ -216,32 +216,15 @@ docs: build ## Generate CLI reference docs (use CMD="attest snyk" for one comman
@./kosli docs --dir client_reference $(CMD)
@echo "Docs written to client_reference/"
-# Suggest next semver and changelog using Claude.
-# Writes changelog to dist/release_notes.md, used as the annotated tag's body.
-# Requires: jq, curl, op (1Password CLI). API key from 1Password via op.
-# Usage: make suggest-version-ai [BASE_REF=v1.2.3]
-suggest-version-ai: ## Suggest next version using AI
- @command -v jq >/dev/null 2>&1 || (echo "Install jq (e.g. brew install jq)" && exit 1)
- @command -v curl >/dev/null 2>&1 || (echo "Install curl (e.g. brew install curl)" && exit 1)
- @bin/suggest-version-ai.sh $(BASE_REF) -o dist/release_notes.md
-
-# Release: without tag → suggest version + changelog, then interactive edit & confirm, then tag and push.
-# With tag → escape hatch: create annotated tag (body = dist/release_notes.md if present), push. No AI, no prompt.
+# Creates an annotated tag (body = dist/release_notes.md if present) and pushes it.
# Release notes are carried in the tag message so GitHub Actions can pass them to GoReleaser.
-release: ## Cut a new release and push next tag
+# --cleanup=verbatim stops git stripping the notes' markdown '#' headings as comments.
+# To draft the version and notes with Claude, use the release skill (/release in Claude Code).
+release: ## Cut a new release and push the tag: make release tag=vX.Y.Z
@current=$$(git branch --show-current 2>/dev/null || git rev-parse --abbrev-ref HEAD); \
if [ "$$current" != "main" ]; then echo "ERROR: release must be run from main branch (current: $$current)"; exit 1; fi; \
- if [ -z "$(tag)" ]; then \
- command -v jq >/dev/null 2>&1 || (echo "Install jq (e.g. brew install jq)" && exit 1); \
- command -v curl >/dev/null 2>&1 || (echo "Install curl (e.g. brew install curl)" && exit 1); \
- bin/suggest-version-ai.sh -o dist/release_notes.md; \
- if [ ! -f dist/suggested_version ]; then \
- echo "Suggestion failed or no previous tag. Use: make release tag=vX.Y.Z"; exit 1; \
- fi; \
- bin/release-interactive.sh; \
- else \
- git remote update; \
- git status -uno | grep --silent "Your branch is up to date" || (echo "ERROR: your branch is NOT up to date with remote" && exit 1); \
- ([ -f dist/release_notes.md ] && git tag -a $(tag) --cleanup=verbatim -F dist/release_notes.md) || git tag -a $(tag) -m"$(tag)"; \
- git push origin $(tag); \
- fi
+ if [ -z "$(tag)" ]; then echo "Usage: make release tag=vX.Y.Z (or run /release in Claude Code to draft version and notes)"; exit 1; fi; \
+ git remote update; \
+ git status -uno | grep --silent "Your branch is up to date" || (echo "ERROR: your branch is NOT up to date with remote" && exit 1); \
+ ([ -f dist/release_notes.md ] && git tag -a $(tag) --cleanup=verbatim -F dist/release_notes.md) || git tag -a $(tag) -m"$(tag)"; \
+ git push origin $(tag)
diff --git a/bin/release-interactive.sh b/bin/release-interactive.sh
deleted file mode 100755
index 348a04e67..000000000
--- a/bin/release-interactive.sh
+++ /dev/null
@@ -1,59 +0,0 @@
-#!/usr/bin/env bash
-# Interactive step after suggest-version-ai: show version and release notes,
-# let user edit notes, then confirm before creating tag and pushing.
-# Called from Make when running `make release` (no tag).
-# Requires: dist/suggested_version and dist/release_notes.md exist.
-
-set -e
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
-cd "$REPO_ROOT"
-
-SUGGESTED_VERSION_FILE="dist/suggested_version"
-RELEASE_NOTES_FILE="dist/release_notes.md"
-
-if [ ! -f "$SUGGESTED_VERSION_FILE" ] || [ ! -f "$RELEASE_NOTES_FILE" ]; then
- echo "Missing $SUGGESTED_VERSION_FILE or $RELEASE_NOTES_FILE. Run suggest-version-ai first." >&2
- exit 1
-fi
-
-VER=$(cat "$SUGGESTED_VERSION_FILE")
-if [ -z "$VER" ]; then
- echo "Suggested version is empty. Run suggest-version-ai or use: make release tag=vX.Y.Z" >&2
- exit 1
-fi
-
-echo "Suggested tag: $VER"
-echo ""
-echo "Release notes ($RELEASE_NOTES_FILE):"
-echo "---"
-cat "$RELEASE_NOTES_FILE"
-echo "---"
-echo ""
-
-# Let user edit release notes
-read -r -p "Edit release notes? [y/N] " edit_notes
-case "$edit_notes" in
- y|Y) "${EDITOR:-vi}" "$RELEASE_NOTES_FILE" ;;
- *) ;;
-esac
-
-echo ""
-read -r -p "Create tag $VER and push? [y/N] " confirm
-case "$confirm" in
- y|Y) ;;
- *) echo "Aborted. To release later run: make release tag=$VER"; exit 0 ;;
-esac
-
-git remote update
-if ! git status -uno | grep -q "Your branch is up to date"; then
- echo "ERROR: your branch is NOT up to date with remote" >&2
- exit 1
-fi
-
-# --cleanup=verbatim: git's default cleanup strips lines starting with '#' as
-# commentary, which would eat the markdown headings in the release notes.
-git tag -a "$VER" --cleanup=verbatim -F "$RELEASE_NOTES_FILE"
-git push origin "$VER"
-echo "Pushed tag $VER. Release workflow will run on GitHub."
diff --git a/bin/suggest-version-ai.sh b/bin/suggest-version-ai.sh
deleted file mode 100755
index 8ec327e65..000000000
--- a/bin/suggest-version-ai.sh
+++ /dev/null
@@ -1,169 +0,0 @@
-#!/usr/bin/env bash
-# Suggest next semver and changelog by sending the git diff to Claude.
-# Does not rely on commit messages. Changelog will be attached as a header to release Changelog.
-#
-# Auth (first non-empty wins):
-# - ANTHROPIC_API_KEY: call Claude directly.
-# - OP_ANTHROPIC_API_KEY_REF: 1Password reference (default below; override if your item path differs).
-#
-# Optional: CLAUDE_MODEL (default: claude-sonnet-5)
-#
-# Requires: curl, jq; for 1Password: op CLI
-# Usage: bin/suggest-version-ai.sh [base_ref] [-o release_notes.md]
-# base_ref defaults to the latest git tag.
-# -o FILE write changelog markdown to FILE (default: dist/release_notes.md)
-#
-# Output: bump (major|minor|patch), next_version (e.g. v1.3.0), and changelog file.
-
-set -euo pipefail
-
-BASE_REF=""
-RELEASE_NOTES_FILE="dist/release_notes.md"
-while [[ $# -gt 0 ]]; do
- case "$1" in
- -o) RELEASE_NOTES_FILE="$2"; shift 2 ;;
- *) BASE_REF="$1"; shift ;;
- esac
-done
-BASE_REF="${BASE_REF:-$(git describe --tags --abbrev=0 2>/dev/null)}"
-SUGGESTED_VERSION_FILE="$(dirname "$RELEASE_NOTES_FILE")/suggested_version"
-
-if [ -z "$BASE_REF" ]; then
- echo "ERROR: No base ref. Pass a tag or branch, or create a tag first." >&2
- exit 1
-fi
-
-# Cap diff size to stay within context (avoid SIGPIPE when diff is large: head exits first)
-MAX_DIFF_CHARS=50000
-set +o pipefail
-DIFF="$(git diff "$BASE_REF"..HEAD 2>/dev/null | head -c "$MAX_DIFF_CHARS")"
-set -o pipefail
-
-# Get API key from 1Password if not set (default ref; override with OP_ANTHROPIC_API_KEY_REF)
-OP_ANTHROPIC_API_KEY_REF="${OP_ANTHROPIC_API_KEY_REF:-op://Shared/Anthropic API Key/credential}"
-if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
- if command -v op >/dev/null 2>&1; then
- ANTHROPIC_API_KEY=$(op read "$OP_ANTHROPIC_API_KEY_REF" 2>/dev/null) || true
- fi
-fi
-if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
- echo "ERROR: Set ANTHROPIC_API_KEY or OP_ANTHROPIC_API_KEY_REF (1Password)." >&2
- exit 1
-fi
-
-# Remove stale outputs from a previous run so a failure partway through doesn't mislead the next invocation.
-trap 'rm -f "$RELEASE_NOTES_FILE" "$SUGGESTED_VERSION_FILE"' EXIT
-
-if [ -z "$DIFF" ]; then
- echo "No changes since $BASE_REF. Bump: patch (no change)." >&2
- CHANGELOG="No code changes since $BASE_REF."
- mkdir -p "$(dirname "$RELEASE_NOTES_FILE")"
- echo "$CHANGELOG" > "$RELEASE_NOTES_FILE"
- echo "patch"
- CURRENT="${BASE_REF#v}"
- if [[ "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
- MAJOR="${CURRENT%%.*}"; REST="${CURRENT#*.}"; MINOR="${REST%%.*}"; PATCH="${REST#*.}"; PATCH="${PATCH%%[-+]*}"
- NEXT="v${MAJOR}.${MINOR}.$((PATCH+1))"
- if [ -n "$(git tag -l "$NEXT")" ]; then
- echo "ERROR: tag $NEXT already exists. Push it or use: make release tag=$NEXT" >&2
- exit 1
- fi
- echo "$NEXT" > "$SUGGESTED_VERSION_FILE"
- echo "$NEXT"
- fi
- trap - EXIT
- exit 0
-fi
-
-PROMPT="You are a release engineer. Given the following git diff for a CLI application (Kosli CLI), do two things.
-
-Scope: Consider ONLY changes to the CLI itself—i.e. code under cmd/ and internal/ that affects user-facing commands, flags, and behavior. IGNORE all other changes when deciding the version and when writing the changelog:
-- Ignore: documentation (docs*, *.md), Helm charts (charts/), CI/workflows (.github/), scripts (bin/, scripts/), tests (*_test.go, testdata/), Makefile, config files, and any other non-CLI code.
-- If the diff contains only ignored changes, recommend a patch bump and write a single short line for the changelog (e.g. \"No user-facing CLI changes.\").
-
-1) Suggest the semantic version bump (based only on CLI changes):
- - major: Breaking changes (removed/renamed commands or flags, changed default behavior).
- - minor: New commands, flags, subcommands, or features.
- - patch: Bug fixes, refactors, internal or dependency updates; or no user-facing CLI changes.
-
-2) Write a short changelog in markdown for the GitHub release body. Include only user-facing CLI changes. Use bullet points; be concise; no preamble.
- - Structure the changelog with section headers (e.g. \"# Breaking changes\", \"# New features\", \"# Bug fixes\" or \"# Improvements\") and list items under each header. Use only headers that have at least one change—omit any section that would be empty.
- - Do not write placeholder lines under any header (no \"No other changes\", \"No user-facing CLI changes in this release\", or similar). If there are no CLI changes at all, output a single short line only (no headers).
-
-Reply in this exact format (no other text before or after):
-BUMP: major|minor|patch
----CHANGELOG---
-"
-
-CLAUDE_MODEL="${CLAUDE_MODEL:-claude-sonnet-5}"
-DIFF_FILE=$(mktemp)
-trap 'rm -f "$DIFF_FILE" "$RELEASE_NOTES_FILE" "$SUGGESTED_VERSION_FILE"' EXIT
-printf '%s' "$DIFF" > "$DIFF_FILE"
-BODY=$(jq -n \
- --arg model "$CLAUDE_MODEL" \
- --arg prompt "$PROMPT" \
- --rawfile diff "$DIFF_FILE" \
- '{model: $model, max_tokens: 4096, messages: [{role: "user", content: ($prompt + "\n\n--- diff ---\n\n" + $diff)}]}')
-
-RESPONSE=$(curl -s -S -X POST "https://api.anthropic.com/v1/messages" \
- -H "x-api-key: $ANTHROPIC_API_KEY" \
- -H "anthropic-version: 2023-06-01" \
- -H "Content-Type: application/json" \
- -d "$BODY")
-
-# The response may start with a thinking block, so join all text blocks instead of reading content[0].
-CONTENT=$(echo "$RESPONSE" | jq -r '[.content[]? | select(.type == "text") | .text] | join("\n")')
-if [ -z "$CONTENT" ]; then
- echo "ERROR: Anthropic API failed or returned no content. Response:" >&2
- echo "$RESPONSE" | jq . >&2
- exit 1
-fi
-
-set +o pipefail
-BUMP=$(echo "$CONTENT" | tr '[:upper:]' '[:lower:]' | grep -oE 'major|minor|patch' | head -1)
-set -o pipefail
-case "$BUMP" in
- major|minor|patch) ;;
- *)
- echo "WARN: Could not parse bump (got: $CONTENT). Defaulting to patch." >&2
- BUMP=patch
- ;;
-esac
-
-CHANGELOG_MARKER='---CHANGELOG---'
-set +o pipefail
-if echo "$CONTENT" | grep -qF -- "$CHANGELOG_MARKER"; then
- CHANGELOG=$(echo "$CONTENT" | sed -n "/${CHANGELOG_MARKER}/,\$ p" | tail -n +2)
-else
- CHANGELOG=$(echo "$CONTENT" | sed -n '2,$ p')
-fi
-set -o pipefail
-mkdir -p "$(dirname "$RELEASE_NOTES_FILE")"
-echo "$CHANGELOG" > "$RELEASE_NOTES_FILE"
-
-# Compute next version from current tag
-CURRENT="${BASE_REF#v}"
-if [[ "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
- MAJOR="${CURRENT%%.*}"; REST="${CURRENT#*.}"; MINOR="${REST%%.*}"; PATCH="${REST#*.}"; PATCH="${PATCH%%[-+]*}"
- case "$BUMP" in
- major) NEXT="v$((MAJOR+1)).0.0" ;;
- minor) NEXT="v${MAJOR}.$((MINOR+1)).0" ;;
- patch) NEXT="v${MAJOR}.${MINOR}.$((PATCH+1))" ;;
- esac
-else
- NEXT=""
-fi
-
-if [ -n "$NEXT" ]; then
- if [ -n "$(git tag -l "$NEXT")" ]; then
- echo "ERROR: tag $NEXT already exists. Push it or use: make release tag=$NEXT" >&2
- exit 1
- fi
- echo "$NEXT" > "$SUGGESTED_VERSION_FILE"
-fi
-echo "Suggested bump: $BUMP (from diff since $BASE_REF)" >&2
-echo "Next version: $NEXT" >&2
-echo "Changelog: $RELEASE_NOTES_FILE" >&2
-trap - EXIT
-echo "$BUMP"
-echo "$NEXT"
diff --git a/release-guide.md b/release-guide.md
index 0c168d0af..d14b5b39d 100644
--- a/release-guide.md
+++ b/release-guide.md
@@ -24,47 +24,35 @@ Triggers when files under `charts/` change on `main`:
## Release process
-### Default: interactive release (AI version and changelog)
+### Default: release with the Claude Code `release` skill
-Run:
+In Claude Code, on an up-to-date `main`, run `/release` (or ask Claude to cut a release). The skill at `.claude/skills/release/SKILL.md`:
-```bash
-make release
-```
-
-**Prerequisites:**
-
- You need:
-
-- **jq**, **curl** installed.
-- An Anthropic API key, provided in one of the following ways:
- - **Via 1Password**: The **1Password CLI** (`op`), access to the shared vault, and the **1Password desktop app** linked to the CLI so `op` can read secrets. In the 1Password app: **Settings → Developer → Integrate with 1Password CLI**. See [Turn on the 1Password desktop app integration](https://developer.1password.com/docs/cli/get-started#step-2-turn-on-the-1password-desktop-app-integration).
- - **Via environment variable**: Set `ANTHROPIC_API_KEY` directly in your environment (no 1Password required).
-
-**What happens:**
+1. Reads the commits and diffs since the last tag and picks out the user-facing CLI changes.
+2. Suggests the next semver tag, with the changes that justify the bump.
+3. Drafts `dist/release_notes.md` and revises it with you until you approve it.
+4. After a final confirmation, runs `make release tag=vX.Y.Z`, which creates an annotated tag with the release notes as the tag body and pushes it. The `release.yml` workflow reads the notes from the tag body and passes them to GoReleaser, which puts them at the top of the GitHub Release.
-1. A script uses Claude to suggest the next semver and draft release notes from the **git diff** (no commit messages). It reads the API key from 1Password via `op` using a default secret reference (vault/item/field); you can override with `OP_ANTHROPIC_API_KEY_REF` if your item lives elsewhere.
-2. You see the suggested tag and release notes. You can press Enter to open your editor and edit `dist/release_notes.md`, or Enter to skip.
-3. You are prompted: **Create tag vX.Y.Z and push? [y/N]**. On **y**, an annotated tag is created with the release notes as the tag body and pushed. The `release.yml` workflow runs on GitHub; it reads the notes from the tag body and passes them to GoReleaser, which puts them at the top of the GitHub Release. On **n**, nothing is pushed.
+It runs under your own Claude login; no Anthropic API key is involved.
### Release body layout
The release body has two parts:
-1. The notes carried in the tag body — AI-drafted, edited by you, describing user-facing CLI changes.
+1. The notes carried in the tag body — drafted with the `release` skill or written by hand, describing user-facing CLI changes.
2. GitHub's **What's Changed** list of merged PRs and the **Full Changelog** compare link, from GoReleaser's `github-native` changelog. Dependency bumps are filtered out by `.github/release.yml`.
A tag with no notes gets part 2 only.
-### Fallback: release with an explicit tag
+### Without Claude Code: release with an explicit tag
-If you don’t want the AI flow (e.g. 1Password/`op` not available or the suggest step failed), run:
+Without Claude Code, run:
```bash
make release tag=v2.x.y
```
-This checks the branch is up to date, creates an annotated tag (using `dist/release_notes.md` as the tag body if that file exists, otherwise the version string), and pushes it. No prompt. The release workflow runs as above; if the tag has no notes, the release body is the auto-generated **What's Changed** list alone.
+This checks the branch is up to date, creates an annotated tag (using `dist/release_notes.md` as the tag body if that file exists, otherwise the version string), and pushes it. No prompt, so check that any `dist/release_notes.md` left over from an earlier release is gone or rewritten first. The release workflow runs as above; if the tag has no notes, the release body is the auto-generated **What's Changed** list alone.
### 1. Pre-build