Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
b93b245
feat: CLI scaffold with config, output envelope and poll engine
chandrasekharan-zipstack Aug 11, 2026
ee211ee
feat: derive command flags from the committed API specs
chandrasekharan-zipstack Aug 12, 2026
bb46d73
feat: the v1 command surface for both products
chandrasekharan-zipstack Aug 12, 2026
af9b851
feat: --discover and a live probe for config doctor
chandrasekharan-zipstack Aug 12, 2026
b6ec319
test: pin the parameters no command can reach
chandrasekharan-zipstack Aug 12, 2026
2d01eae
fix(whisper): read the highlight metadata the service actually returns
chandrasekharan-zipstack Aug 12, 2026
14f7872
feat: connection flags, wider clients, and honest one-shot wording
chandrasekharan-zipstack Aug 12, 2026
6dfc2a9
Forward the status parameters, and stop the doctor overstating itself
chandrasekharan-zipstack Aug 12, 2026
d0dc35f
Report which job a waited result belongs to
chandrasekharan-zipstack Aug 12, 2026
afb864b
Stop losing one-shot results, and stop printing keys
chandrasekharan-zipstack Aug 12, 2026
52761ad
Print a table by default, and version the JSON
chandrasekharan-zipstack Aug 12, 2026
14a1966
feat: expose the deployment client's socket timeout
chandrasekharan-zipstack Aug 12, 2026
e0a91ea
fix: report an interrupt as an interrupt
chandrasekharan-zipstack Aug 12, 2026
19d6001
test: pin which of the three help sources wins
chandrasekharan-zipstack Aug 12, 2026
4f80f52
fix: take allowed values from the spec, not from a copy of them
chandrasekharan-zipstack Aug 12, 2026
5229320
fix: strip a restated default that contains a period
chandrasekharan-zipstack Aug 12, 2026
6ac0646
build: move the client pins to the heads the specs were taken from
chandrasekharan-zipstack Aug 12, 2026
bc9b255
docs: trim comments that narrate rather than explain
chandrasekharan-zipstack Aug 12, 2026
a974329
feat: add the `clone` command
chandrasekharan-zipstack Aug 12, 2026
d515a6f
fix: resync the docstudio spec and pin the flags it derives
chandrasekharan-zipstack Aug 12, 2026
f919eb5
fix: keep the job handle on any mid-poll failure, and fail a failed s…
chandrasekharan-zipstack Aug 12, 2026
50fd6e5
fix: report a failure as one, and never authenticate against a guess
chandrasekharan-zipstack Aug 12, 2026
b9f04ac
build: move the client pins to the heads carrying the transport fixes
chandrasekharan-zipstack Aug 12, 2026
dbf730c
fix: hold the clone's guards, and say what a clone left behind
chandrasekharan-zipstack Aug 12, 2026
a8b1ac9
build: move the deployment client pin to the poll-URL fix
chandrasekharan-zipstack Aug 12, 2026
7092cdb
docs: draft the release notes, and move the client pin to its tip
chandrasekharan-zipstack Aug 13, 2026
6ecd490
docs: shorten the top-level help to what a first run needs
chandrasekharan-zipstack Aug 13, 2026
85e4e69
fix: do not let a discovered project config name the host or the key
chandrasekharan-zipstack Aug 13, 2026
4d75745
fix: never write config through a symlink a checkout chose
chandrasekharan-zipstack Aug 13, 2026
14bdda2
docs: one key can cover every deployment, and say where it is minted
chandrasekharan-zipstack Aug 13, 2026
48ed23f
test: pin the trust classification and the config group's own warnings
chandrasekharan-zipstack Aug 13, 2026
f6da68e
test: snapshot what each derived flag accepts, not just its name
chandrasekharan-zipstack Aug 13, 2026
473356d
minor: Edit in discover docstring to clarify intent
chandrasekharan-zipstack Aug 17, 2026
014d28c
fix: treat an empty config value as unset
chandrasekharan-zipstack Aug 18, 2026
b42691f
docs: drop the release notes and the runbook
chandrasekharan-zipstack Aug 18, 2026
88ee5c8
docs: install with uv, and answer what the README left open
chandrasekharan-zipstack Aug 18, 2026
f2553a4
docs: cut each comment back to the reason it exists
chandrasekharan-zipstack Aug 18, 2026
c62a14d
docs: say in the top-level help what the CLI can do
chandrasekharan-zipstack Aug 18, 2026
eb4414b
fix: close two local write windows on the config and result paths
chandrasekharan-zipstack Aug 19, 2026
86ed7cf
feat: install with one command, without a Python of the right version
chandrasekharan-zipstack Sep 1, 2026
ea77498
feat: build on the released clients, against the specs they came from
chandrasekharan-zipstack Sep 1, 2026
4a2d95a
ci: publish a release to PyPI from a dispatch
chandrasekharan-zipstack Sep 1, 2026
f14ee3c
ci: cut release candidates before a stable release
chandrasekharan-zipstack Sep 1, 2026
bad14d8
fix: answer honestly where a live run found the CLI lying
chandrasekharan-zipstack Sep 1, 2026
14f7fef
fix: never truncate a working config ahead of a write that may fail
chandrasekharan-zipstack Sep 2, 2026
4220be4
fix: say why a config in an unwritable directory cannot be saved
chandrasekharan-zipstack Sep 2, 2026
dbd2efd
fix: make raw output and a dead DNS guard answer honestly
chandrasekharan-zipstack Sep 2, 2026
4991834
ci: compare both ends of the click range, not the lockfile's middle
chandrasekharan-zipstack Sep 2, 2026
1c07d11
fix: put the new config on the disk before the rename that stands for it
chandrasekharan-zipstack Sep 3, 2026
9bb89df
fix: refuse a symlinked --save target instead of replacing the link
chandrasekharan-zipstack Sep 3, 2026
d21da48
fix: check the save target again at the rename, not only ahead of the…
chandrasekharan-zipstack Sep 3, 2026
08ce3f0
fix: keep one symlink check on the save path, at the point it can sti…
chandrasekharan-zipstack Sep 8, 2026
ba414ad
ci: install from the lockfile, on the uv the other packages release with
chandrasekharan-zipstack Sep 8, 2026
c20d09e
docs: add a skill for bumping the client pins and their specs
chandrasekharan-zipstack Sep 8, 2026
8a5dfda
docs: check all four provenance fields, and hold version_bump across …
chandrasekharan-zipstack Sep 8, 2026
7f5cdc7
Merge pull request #6 from Zipstack/docs/bump-client-pins-skill
chandrasekharan-zipstack Sep 8, 2026
4e9b1d1
Remediate review findings across the CLI scaffold
chandrasekharan-zipstack Sep 10, 2026
fd35bd5
fix: make the release workflow parseable, and keep it that way
chandrasekharan-zipstack Sep 11, 2026
e70681c
fix: let a run name its documents as presigned URLs
chandrasekharan-zipstack Sep 11, 2026
f9dfd6b
fix: give an accepted extraction a raw answer of its own
chandrasekharan-zipstack Sep 11, 2026
f46b650
fix: write the starter config where it will be honoured
chandrasekharan-zipstack Sep 11, 2026
4fd39a8
fix: read past an empty field when picking a raw answer
chandrasekharan-zipstack Sep 11, 2026
72e4e90
fix: keep a rescued result whole when a save fails
chandrasekharan-zipstack Sep 11, 2026
50a1122
fix: render a failure in the format the run resolved
chandrasekharan-zipstack Sep 11, 2026
6071465
fix: do not lose a finished report to an unreadable config
chandrasekharan-zipstack Sep 11, 2026
22709ce
fix: name the execution id when a run times out
chandrasekharan-zipstack Sep 11, 2026
b9481f5
test: cover building a deployment client
chandrasekharan-zipstack Sep 11, 2026
e46410b
ci: publish only after the revertible release steps have run
chandrasekharan-zipstack Sep 11, 2026
753b0a3
docs: state how a deployment alias and the connection flags interact
chandrasekharan-zipstack Sep 11, 2026
fc2ea72
fix: bound the poll interval and floor the backoff
chandrasekharan-zipstack Sep 11, 2026
2df7c5b
fix: preflight the write a save actually performs
chandrasekharan-zipstack Sep 11, 2026
11c0a86
fix: stop reporting a local fault as a retryable server failure
chandrasekharan-zipstack Sep 11, 2026
3eabc6a
feat: publish what omitting a spec-derived flag gets you
chandrasekharan-zipstack Sep 11, 2026
3fccf12
test: check the token where gh actually reads it
chandrasekharan-zipstack Sep 11, 2026
79e961c
fix: answer a bare invocation in the format that was asked for
chandrasekharan-zipstack Sep 11, 2026
4d5171a
fix: put every diagnostic behind --quiet
chandrasekharan-zipstack Sep 11, 2026
dcd6f8b
keep the parts of a config file this CLI does not own
chandrasekharan-zipstack Sep 11, 2026
dcd327f
say on stderr when a clone left something behind
chandrasekharan-zipstack Sep 11, 2026
dfd7102
refuse a config setting the product does not have
chandrasekharan-zipstack Sep 11, 2026
c11e36c
name an overlay entry the specs do not declare
chandrasekharan-zipstack Sep 11, 2026
16f0799
cover the failure paths that had no test
chandrasekharan-zipstack Sep 11, 2026
3dbdca0
drop the header redaction nothing calls
chandrasekharan-zipstack Sep 11, 2026
cf38f3d
fix: give the deployment client a socket timeout by default
chandrasekharan-zipstack Sep 11, 2026
c7d22a7
ci: authenticate the release push the way the other repos do
chandrasekharan-zipstack Sep 11, 2026
5bad0b9
docs: say what the vendored-spec hash does and does not catch
chandrasekharan-zipstack Sep 11, 2026
c8233e4
test: tie each vendored spec to the client pin it was synced for
chandrasekharan-zipstack Sep 11, 2026
1ac518e
fix: a completed run with a failed document exits VALIDATION
chandrasekharan-zipstack Sep 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions .claude/skills/bump-client-pins/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
name: bump-client-pins
description: Bump the exact `unstract-client` / `llmwhisperer-client` pins, re-sync the vendored specs to match, and cut a CLI release. Use whenever a new version of either client is released, when `tests/test_specs.py` or `tests/test_contract.py` fails, when the CLI is missing a flag for an endpoint the API already has, or when someone asks to "bump the client", "update the pins", "refresh the specs", or "release the CLI". Reach for this even when the request sounds like a plain dependency bump — the pins, the vendored specs and `provenance.json` have to move together or the CLI derives flags the pinned client cannot carry.
---

# Bumping the client pins

The CLI derives its flags and help text from two published clients and from
vendored copies of the specs those clients were generated from. That makes a pin
bump three coupled edits, not one: the pin, the spec, and the provenance record.
Move one without the others and the tests say so — which is the point of them.

## The pieces

| Thing | Where |
|---|---|
| Exact pins | `pyproject.toml`, `[project].dependencies` |
| Vendored specs | `src/unstract_cli/specs/{docstudio,llmwhisperer}.json` |
| Provenance | `src/unstract_cli/specs/provenance.json` (client pin, upstream repo, commit, sha256) |
| Coherence tests | `tests/test_specs.py`, `tests/test_contract.py`, `tests/derived_flags.json` |
| Release | `.github/workflows/release.yml`, `workflow_dispatch` |

`src/unstract_cli/specs/README.md` explains the vendoring rule in place; read it
if any of the below is unclear.

## The sequence

1. **Move the pins** in `pyproject.toml` to the released versions you are
upgrading to. They are exact (`==`) on purpose: the CLI's published surface is
derived from these clients, so a client that moves reshapes the CLI.

2. **Relock:** `uv lock` then `uv sync --extra dev --python 3.12`. CI installs
from `uv.lock`, not from a fresh resolve, so a lockfile left behind means the
gate tests a dependency set nobody ships.

3. **Re-sync each vendored spec from the commit the pinned client was generated
from.** The chain is: the client repo's release tag → its `tools/gen_sdk.sh`,
which records the upstream service repo, path and revision the spec was copied
from → the spec file committed in that client at that tag. Copy that file here
byte-for-byte. Copying from anywhere else — upstream `main`, a newer service
commit — is what `tests/test_contract.py` guards: a spec parameter the pinned
client has no argument for cannot become a flag.

4. **Update `provenance.json`** for each spec you moved. Set `client` to the
exact pin you wrote in `pyproject.toml` (`unstract-client==X.Y.Z`); the
tests compare the two, so a pin moved without its spec fails here. Check
`repo`, `path` and `commit` against what that client's `tools/gen_sdk.sh`
records, because an upstream that moved its spec file leaves `repo` and
`path` stale and the tests cannot see it: they check the pin, the `sha256`
and the entry names, nothing about where the file came from. The `sha256`
is of the file you just wrote (`sha256sum src/unstract_cli/specs/<file>`).
This record is what lets the next person tell a current copy from a stale
one.

5. **Run the tests:** `uv run pytest -q`.

- `test_specs.py` fails if a vendored file stops matching its recorded
sha256, if its `client` no longer equals the pin in `pyproject.toml`, or if
a spec has no provenance entry. It is the cheap check that steps 1, 3 and
4 actually agree.
- `test_contract.py` fails if a spec parameter the pinned client cannot accept
would have become a flag, and separately if the derived flags stop matching
`tests/derived_flags.json`.

6. **If `derived_flags.json` fails, read the difference before refreshing it.**
The failure names the flags that moved. A flag missing from the new set is a
flag the CLI has stopped offering; a narrowed choice or changed type is a value
the CLI used to take and now rejects. Once you have decided the change is
intended, refresh it deliberately:

```bash
UNSTRACT_CLI_REFRESH_FLAG_SNAPSHOT=1 uv run pytest -q tests/test_contract.py
```

and commit the snapshot in the same PR, so the diff shows what the CLI's
surface gained or lost.

7. **Lint:** `uv run ruff check . && uv run ruff format --check .` — the release
run repeats exactly this, so a failure here is a failure there.

## Versioning and release

Choose the bump by what changed for CLI users: **minor** for new or changed
flags and commands, **patch** for fixes that leave the surface identical.

Do not bump `__version__` in `src/unstract_cli/__init__.py` in your PR. The
in-repo value names the last released version; `release.yml` reads it, applies
the bump chosen at dispatch and commits the result itself.

Release by dispatching **Release Tag and Publish Package** on `main`:

- `version_bump: none` publishes the version already in the repo — what the
first release of a version needs.
- `pre_release: true` publishes `<next-version>rcN` and deliberately leaves the
committed version alone, counting N up from the rc tags already published for
that target. To promote to stable, dispatch again with it off **and the same
`version_bump`**: the workflow recomputes the target from that input every
time, so a different bump publishes a different version than the one the rc
tested.
- It publishes to PyPI **before** it tags and releases, because publishing is the
only step that cannot be undone: a failure before it leaves nothing to
unpublish, and one after it is retried by hand against a live artifact.

## Upstream

If a client pin is missing an endpoint the service already offers, the fix is in
that client, not here — see the `spec-upgrade` skill in `unstract-python-client`
and `llm-whisperer-python-client`. Bump the pin here once it is released.
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: ci

on:
pull_request:
push:
branches: [main]

jobs:
# Offline by design: no network, no credentials, sub-second. Live round trips
# are a manual pre-release step, not a per-PR gate.
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
version: "0.6.14"
enable-cache: true
# Synced from uv.lock rather than resolved fresh, so the gate tests the
# dependency set an install actually gets.
- run: uv sync --extra dev --python 3.12
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run pytest -q
# `uv run` resolves from uv.lock, whose click sits in the middle of the
# range the pin allows. Both ends are what an install in the wild gets,
# and discovery is a published contract read out of Click's own objects,
# so both ends are run and their answers compared.
- run: uv pip install 'click~=8.1.0'
- run: .venv/bin/python -m pytest -q
- run: .venv/bin/python -m unstract_cli -o json --discover full > floor.json
- run: uv pip install -U 'click>=8.1,<9'
- run: .venv/bin/python -m pytest -q
- run: .venv/bin/python -m unstract_cli -o json --discover full > latest.json
- run: diff floor.json latest.json
178 changes: 178 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
name: Release Tag and Publish Package

on:
workflow_dispatch:
inputs:
version_bump:
description: "Version bump type. `none` publishes the version already in the repo, which is what the first release needs."
required: true
default: "patch"
type: choice
options:
- patch
- minor
- major
- none
pre_release:
description: "Publish a release candidate (`<version>rcN`) instead of the version itself. Dispatch again with this off to promote the same version to stable."
required: false
default: false
type: boolean
release_notes:
description: "Release notes (optional)"
required: false
type: string

concurrency:
group: release
cancel-in-progress: false

jobs:
release-and-publish:
runs-on: ubuntu-latest
permissions:
contents: write
# Publishing is by PyPI Trusted Publisher, so there is no API token.
id-token: write
steps:
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.PUSH_TO_MAIN_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_TO_MAIN_APP_PRIVATE_KEY }}
owner: Zipstack
repositories: |
unstract-cli

- uses: actions/checkout@v4
with:
token: ${{ steps.generate-token.outputs.token }}
fetch-depth: 0

- name: Configure Git
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- uses: astral-sh/setup-uv@v6
with:
version: "0.6.14"
enable-cache: true

# The same install as ci.yml, so what the release run lints and tests is
# what the PR gate lints and tests.
- run: uv sync --extra dev --python 3.12

# Staged locally only: nothing is committed, tagged or released until the
# checks and the build have passed, so a failure leaves main untouched.
- name: Compute new version
id: version
run: |
VERSION_FILE=src/unstract_cli/__init__.py
CURRENT_VERSION=$(sed -nE 's/^__version__ = "(.*)"/\1/p' "$VERSION_FILE")
echo "Current version: $CURRENT_VERSION"

IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
case "${{ github.event.inputs.version_bump }}" in
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
patch) PATCH=$((PATCH + 1)) ;;
esac
NEXT_VERSION="$MAJOR.$MINOR.$PATCH"

# A pre-release is a candidate for NEXT_VERSION, not a version of its
# own, so it never moves the committed one: the file keeps naming the
# last stable release, and repeat dispatches count up from the rc tags
# already published for that target.
if [ "${{ github.event.inputs.pre_release }}" = "true" ]; then
HIGHEST_RC=$(git tag -l "v${NEXT_VERSION}rc*" \
| sed -nE "s/^v${NEXT_VERSION}rc([0-9]+)$/\1/p" | sort -n | tail -1)
NEW_VERSION="${NEXT_VERSION}rc$(( ${HIGHEST_RC:-0} + 1 ))"
else
NEW_VERSION="$NEXT_VERSION"
fi

echo "New version: $NEW_VERSION"
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"

sed -i "s/^__version__ = \".*\"/__version__ = \"$NEW_VERSION\"/" "$VERSION_FILE"

if git rev-parse -q --verify "refs/tags/v$NEW_VERSION" >/dev/null; then
echo "Tag v$NEW_VERSION already exists. Exiting..."
exit 1
fi

- name: Verify version update
run: |
BUILT_VERSION=$(uv run python -c "import unstract_cli; print(unstract_cli.__version__)")
echo "Package version: $BUILT_VERSION"
echo "Target version: ${{ steps.version.outputs.version }}"
if [ "$BUILT_VERSION" != "${{ steps.version.outputs.version }}" ]; then
echo "Version mismatch! Exiting..."
exit 1
fi

- name: Run linting
run: |
uv run ruff check .
uv run ruff format --check .

- name: Run tests
run: uv run pytest -q

- name: Build package
run: uv build

# Everything revertible runs first: a tag, a branch and a release can all
# be deleted, and publishing cannot. Failing here leaves nothing on PyPI
# to reconcile; failing the other way around leaves a released version
# that no tag names.
- name: Commit version bump and create release
env:
RELEASE_NOTES: ${{ github.event.inputs.release_notes }}
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
run: |
NEW_VERSION="${{ steps.version.outputs.version }}"

# A pre-release leaves the committed version alone, and `none`
# publishes the version already in the file, so both reach here with
# nothing to commit.
if [ "${{ github.event.inputs.pre_release }}" = "true" ]; then
git checkout -- src/unstract_cli/__init__.py
elif ! git diff --quiet; then
git add src/unstract_cli/__init__.py
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git push origin main
fi

git tag "v$NEW_VERSION"
git push origin "v$NEW_VERSION"

if [ -z "$RELEASE_NOTES" ]; then
gh release create "v$NEW_VERSION" \
--title "Release v$NEW_VERSION" \
--generate-notes \
${{ github.event.inputs.pre_release == 'true' && '--prerelease' || '' }}
else
gh release create "v$NEW_VERSION" \
--title "Release v$NEW_VERSION" \
--notes "$RELEASE_NOTES" \
--generate-notes \
${{ github.event.inputs.pre_release == 'true' && '--prerelease' || '' }}
fi

echo "Created release v$NEW_VERSION"

- name: Publish to PyPI

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Medium] [Lens 11] — the reorder fixes finding #5 but trades one unrecoverable state for another, and the comment overstates what survives

The order is now push-main → tag → release → publish. That does fix the original hazard. But uv publish is the step most likely to fail transiently (PyPI outage, OIDC hiccup, network), and when it does you are left with a public GitHub release for a version that is not installable: main carries the bump, tag vX exists, the release is live, PyPI has nothing.

The retry is blocked. version_bump: none recomputes X and exits at the existing-tag guard (:105-108fetch-depth: 0 at :51 means the tag is visible), while patch publishes X+1 and leaves X permanently absent behind a live release.

The comment at :131-134 says "a tag, a branch and a release can all be deleted" — omitting the fourth artifact, the bump commit already pushed to the default branch, which needs a revert push rather than a delete.

Fix: create the release as a draft and un-draft it after uv publish succeeds; or let a retry skip the tag/push steps when vX already exists rather than exiting 1. If the order is intentional as-is, at minimum correct the comment to name the commit on main and record the manual cleanup sequence.

run: uv publish

- name: Success message
run: |
echo "Published ${{ steps.version.outputs.version }} to PyPI with uv publish using Trusted Publishers"
echo "Release: https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }}"
echo "PyPI: https://pypi.org/project/unstract-cli/${{ steps.version.outputs.version }}/"
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.venv/
__pycache__/
*.egg-info/
.pytest_cache/
.ruff_cache/
dist/
build/
.coverage
.coverage.*
htmlcov/
Loading
Loading