Skip to content

feat(cli): flow commands — the whole cycle without touching Python (#34) - #106

Merged
JArmandoAnaya merged 1 commit into
mainfrom
feat-34-cli-flow
Jul 29, 2026
Merged

feat(cli): flow commands — the whole cycle without touching Python (#34)#106
JArmandoAnaya merged 1 commit into
mainfrom
feat-34-cli-flow

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #34. Also closes #104 (visionset init), absorbed here because #34's first acceptance
criterion — "scripted shell test drives the full cycle via CLI only" — is unsatisfiable without a
command-line way to create a workspace.

What lands

Twenty commands, taking the CLI from four to the full cycle. Every one calls the SDK in-process
— no HTTP hop, no token dance — and every one takes --json.

visionset init [PATH] [--name NAME]
visionset project create NAME [--description TEXT] | list
visionset schema apply FILE --project P [--allow-destructive] | list --project P
visionset ingest PATH --project P [--fps N] [--batch-name NAME]
visionset batch list --project P | approve BATCH [--jobs-of N] | start | complete | promote
visionset job list --batch B | next JOB [-n N] | progress | start | mark JOB ASSET --progress S | complete
visionset release publish --tag T --project P [--split T,V,S] [--seed N] | list | verify TAG
visionset export --project P --release TAG --format F --out DIR [--allow-lossy]
visionset format list
visionset backfill-thumbnails --project P

Twenty-three of the twenty-four are exactly one service call. ingest is the one that is two
(register_images/register_video dispatched on is_dir(), then ingest) and its module says so.

Ledger

  • No migration. FORMAT_VERSION stays 11; VERSION stays 0.0.1.dev0.
  • No new dependency. schema apply is JSON via the stdlib; PyYAML was considered and declined.
  • No new kernel error, nothing added to ERROR_RULES.
  • openapi.json and frontend/ui-core/src/generated/api.ts are both byte-identical — the CLI
    touches no route. Both drift gates verified locally.
  • Two kernel reads, no writes: ProjectService.get_by_name / require_project_named and
    ReleaseService.get_by_tag.
  • 1670 tests, up from 1467.

Decisions worth reviewing

Two new kernel reads rather than resolution in the CLI. A project name is unique
case-insensitively; a release tag is case-sensitive. Those are opposite rules, each enforced
by an index, and a surface re-deriving either from prose is a second spelling free to drift.
TokenService.get_by_name is the precedent. #35 wants both.

--json shapes agree key-for-key with the REST wire models, gated by
tests/cli/test_json_contract.py over fifteen resources — key-set parity and a round-trip through
the wire model, which catches encoding drift a key comparison cannot see. That test imports both
visionset.cli and visionset.server, which tests/ may do and the packages may not. Projections
are hand-written in cli/_json.py, never model_dump(): Asset.uri, Source.path and
Batch.asset_ids stay unpublished, exactly as on the wire.

release verify exits 1 when the answer is no. Not a refusal — the check ran and found damage.
EXIT_ANSWER_IS_NO is a second Final in _errors.py with the same value as EXIT_DOMAIN_ERROR
and its own docstring, so the second meaning of code 1 is written down rather than inferred. It is
grep's and diff's convention and the only way verify && train.sh means anything.

job mark and job next are additions the issue does not list, and without them "the full cycle
without touching Python" is false — nothing else settles an asset, and a batch cannot complete until
every asset has. The wart is stated out loud in docs/jobs.md, in the example and in a test:
--progress annotated records that somebody labeled an asset while the CLI writes no labels, so a
release driven this way carries annotation_count: 0.

Deliberately out, each argued in the docs rather than omitted: batch create/membership editing
(a batch is born from an ingest — #29's reason); BySegments partitioning (the only caller holding
an exact partition is a program, and it is the one partition that can be wrong); project rename/delete and a dataset group (administration and curation, not flow — this PR adds zero
new destructive commands); ingest --resume (re-running the same line is idempotent, so the remedy
needs no new vocabulary).

The traps that shaped the code

  • Three kernel calls raise outside VisionSetError and would print a traceback: --fps <= 0
    (bare ValueError), a missing path (FileNotFoundError), a non-directory (NotADirectoryError).
    Each is refused by Click at exit 2 before the call. The general rule — mirror every domain
    Field(gt=…) bound in the Typer option — also covers --jobs-of (min=1, because BySize.size
    is gt=0) and --split (parsed into a SplitRecipe inside a try).
  • Typer has min= but not Click's min_open, so a gt=0 bound cannot be expressed as an
    option constraint; --fps is checked in the body.
  • schema apply parses through the domain models, so LabelClass/Attribute validators do the
    refusing and nothing is restated. Both non-domain failures (bad JSON, bad shape) are exit 2 — the
    CLI's 422, the same call the server makes by moving them into request parsing.
  • A test module's basename must be unique across the suite. With no __init__.py anywhere,
    tests/cli/test_batches.py beside tests/server/test_batches.py is a collection error, not two
    modules — hence test_<noun>_commands.py. Private helpers are exempt (_flow.py twice) because
    they are imported by full dotted path. Written into docs/cli.md.
  • Typer lists bare commands before groups in --help, preserving declaration order within each
    kind rather than interleaving. Registration is still in cycle order; the comment in main.py says
    what actually happens.
  • dummy is the only installed exporter and it writes nothing, so file_count: 0 is an export
    that ran. The issue's --format yolo does not exist and appears nowhere. The lossy gate is tested
    against an exporter registered for the test, because no installed one declares itself lossy.

The example

examples/cli_end_to_end.shset -euo pipefail, driving only visionset and python3. No
ffmpeg
(stills only), no jq (the always-printed header and id-first column order are what
tail -n +2 | awk '{print $1}' needs), no server. It asserts the --json envelope, tag, asset
count and split recipe — which is acceptance criterion 2 — and ends with a deliberate refusal it
also asserts. tests/examples/test_cli_end_to_end.py runs it by subprocess, which is the only
thing that proves [project.scripts] still works; a fourth CI step runs it standalone.

MCP parity list for #35

visionset init gets no tool — creating a workspace is the agent's sandbox boundary, the same
argument that kept token administration off MCP in #25. Every flow command maps onto a tool already
recorded in PRs #99#102, with two additions this task's surface implies: backfill_thumbnails
and list_formats (the latter already listed by #30). release verify maps to
verify_release, also already listed. Nothing new is invented here.

Checks

ruff check . && ruff format --check .        pass
mypy src/visionset/kernel                    pass (54 files)
mypy src/visionset                           pass (98 files)
lint-imports                                 2 kept, 0 broken
pytest                                       1670 passed
examples/{sdk,ingest}_end_to_end.py          pass
bash examples/cli_end_to_end.sh              pass
scripts/export_openapi.py + git diff         byte-identical
pnpm generate:client:check                   byte-identical

Twenty new commands take the CLI from four to the full cycle: project create /
list, schema apply / list, ingest, batch approve / start / complete / promote,
job list / next / progress / start / mark / complete, release publish / list /
verify, export, format list and backfill-thumbnails. Every one calls the SDK
in-process — no HTTP hop and no token dance — and every one takes --json.

Also lands `visionset init`, closing #104: the first acceptance criterion here is
a scripted shell test driving the cycle via CLI only, and without a command-line
way to create a workspace that is unsatisfiable.

examples/cli_end_to_end.sh walks init → export using nothing but `visionset`,
asserts the --json shapes and one deliberate refusal, and runs twice in CI. It
needs no ffmpeg, no jq and no server.

Two kernel reads, no migration: ProjectService.get_by_name (case-insensitive,
like the index) and ReleaseService.get_by_tag (case-sensitive, like the index).
They live there because the two rules are opposites and a surface re-deriving
either from prose would eventually get one wrong.

--json shapes agree key-for-key with the REST wire models, gated by
tests/cli/test_json_contract.py over fifteen resources — a test may import both
packages where the packages themselves may not.
@JArmandoAnaya
JArmandoAnaya merged commit 0a891a6 into main Jul 29, 2026
3 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat-34-cli-flow branch July 29, 2026 01:54
JArmandoAnaya added a commit that referenced this pull request Aug 21, 2026
…) (#106)

Twenty new commands take the CLI from four to the full cycle: project create /
list, schema apply / list, ingest, batch approve / start / complete / promote,
job list / next / progress / start / mark / complete, release publish / list /
verify, export, format list and backfill-thumbnails. Every one calls the SDK
in-process — no HTTP hop and no token dance — and every one takes --json.

Also lands `visionset init`, closing #104: the first acceptance criterion here is
a scripted shell test driving the cycle via CLI only, and without a command-line
way to create a workspace that is unsatisfiable.

examples/cli_end_to_end.sh walks init → export using nothing but `visionset`,
asserts the --json shapes and one deliberate refusal, and runs twice in CI. It
needs no ffmpeg, no jq and no server.

Two kernel reads, no migration: ProjectService.get_by_name (case-insensitive,
like the index) and ReleaseService.get_by_tag (case-sensitive, like the index).
They live there because the two rules are opposites and a surface re-deriving
either from prose would eventually get one wrong.

--json shapes agree key-for-key with the REST wire models, gated by
tests/cli/test_json_contract.py over fifteen resources — a test may import both
packages where the packages themselves may not.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant