Skip to content

feat: Unstract CLI — unified interface for Document Studio, LLMWhisperer, and API Hub#1

Open
hari-kuriakose wants to merge 21 commits into
mainfrom
pr-graft
Open

feat: Unstract CLI — unified interface for Document Studio, LLMWhisperer, and API Hub#1
hari-kuriakose wants to merge 21 commits into
mainfrom
pr-graft

Conversation

@hari-kuriakose

Copy link
Copy Markdown
Contributor

Establishes the unstract CLI — one interface over Unstract's three products (Document Studio, LLMWhisperer, API Hub), built for agent use: declarative endpoint records, machine-readable --discover, stable exit codes, structured errors, and no interactive prompts.

main is an empty root commit, so this PR's diff is the entire codebase.

Architecture

Every command is generated from a declarative Endpoint record in src/unstract_cli/endpoints/. The command tree, flags, help text, validation, and --discover all derive from those records, so help text cannot drift from behaviour and the bundled Claude Skill has exactly one place to edit.

Highlights

  • Config & resolution — profiles, .unstract.toml, --config; resolution order flag → env → profile → default; config doctor reports where each setting resolves from without echoing secrets.
  • --discover — a ~1k-token group map an agent drills into, rather than pulling all 156 commands into context.
  • --wait — declarative execute → poll → retrieve, deciding terminal state from the response body (not the HTTP code, which the deployment API returns as 422 mid-run).
  • One-shot results--save persists them atomically before exit; a second read exits 9.

Fixes from a full end-to-end run

Driving a document through extract → Prompt Studio → export → deploy → run surfaced real friction; root causes were traced in the backend source, which changed two fixes and ruled one out:

  • Exit codes never reached the shellstandalone_mode=False makes Click return the exit code, and main() discarded it, so every failure exited 0 while its envelope reported the real code. Highest-impact fix; caught only by subprocess tests, since CliRunner never runs main().
  • prompt create --profile-managerfetch_response reads the prompt's own profile and never falls back to the project default (unlike its siblings), failing with a message that names a setting it never reads.
  • prompt-studio --challenge-llm — an empty challenge_llm fails an enum check at deploy time; setting it before export keeps the metadata valid.
  • Param.mirror_as — the key-create routes needed the same id in both path and body; mirroring lets --api-id alone suffice.
  • org_id fallback chains and --wait on index-document.
  • Server-side items that can't be fixed here now have help text describing the real behaviour instead of implying a workaround.

Verification

245 tests pass; ruff and mypy clean; --discover emits valid JSON (156 commands).

🤖 Generated with Claude Code

hari-kuriakose and others added 20 commits July 21, 2026 23:52
Generated command tree from declarative Endpoint records covering
LLMWhisperer, deployments, Platform v1, HITL and API Hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes Phase 6: update-unstract-cli Skill with docs cross-referencing,
zero-drift regression tests, GitHub Actions CI, shell completions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… files param

parse_mdx_doc scanned all <ApiEndpoint> props, so responseBody fields were
read as request parameters (358 false positives across platform v1). Now
reads only pathParams/queryParams/requestBody.

This surfaced a real bug: workflow.execute used 'file' where the API
expects 'files'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Credentials intentionally have no CLI flag (shell history/process listings),
and the block name in the hint now matches what config init writes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unfiltered full output was ~63k tokens, too large for an agent to read
speculatively. Selection (--group/--command) and verbosity (--detail) are
independent axes; default is now names+summaries (~4.5k tokens).

Also: compact JSON when piped, global boilerplate omitted from filtered
views, exit 2 with clean stdout when no command matches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keeps --dump-commands as a hidden deprecated alias that warns on stderr,
since it is referenced in the Skill, README and CI. Internal function
dump_commands() renamed to discover().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--discover is now the only spelling. A test pins the removal so the old
flag cannot creep back via a copied example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t.toml

Config selection was previously env-var-only. Adds a --config flag and
upward discovery of .unstract.toml (stopping at $HOME), composing with
the existing profile mechanism.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Table mode capped cells at 60 chars and appended '...', silently hiding
the tail of notes, URLs and ids. Cells now wrap across lines within the
terminal width, so the human format is lossless like json/yaml/raw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The config commands were described by a hand-maintained parallel data
structure (CONFIG_COMMANDS/HandAuthoredCommand) instead of being
introspected, so the index claimed --product/--key/--value for
'config set', whose parameters are positional. An agent following the
index would build a command line the parser rejects.

Now introspected from the real Click commands, like every generated
command. Each entry gains kind (argument|option) and a usage line. The
duplicate description model is deleted so it cannot drift again, and a
test cross-checks every advertised parameter against the real parser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unstract is the company and the CLI name. It builds three products:
Document Studio (formerly named Unstract), LLMWhisperer and API Hub.

- Product enum now has exactly three members; a new ApiGroup enum carries
  the five API surfaces, with the product derived from it.
- platform/deployment/hitl nest under 'unstract docstudio ...'.
- Config blocks nest by product: [profiles.X.docstudio.platform].
- --discover exposes product, product_name and api_group per command.

Wire paths and UNSTRACT_* env vars are unchanged: the product was
renamed, not the API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
config get/set now accept only docstudio.platform (or 'docstudio platform'),
docstudio.deployment, docstudio.hitl, llmwhisperer, apihub. Bare group names
and the 'whisper' alias are rejected with exit 2 and a hint listing the valid
targets, so a setting always says which product it configures.

Also: config current keys its output by the same target names, and the
variadic arguments declare metavars so --help and --discover both show
'TARGET... KEY VALUE' rather than an opaque 'ARGS...'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Profile blocks now have exactly one accepted layout: the API group nested
under its product. A block written any other way is ignored rather than
half-applied, so a config that looks applied but is not cannot mislead.

Fixes a latent bug found while removing them: ResolvedConfig.get/require
normalised only Product, so an ApiGroup key fell through unconverted --
profile values were silently missed and errors read 'ApiGroup.LLMWHISPERER'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The repo tracked 50 `__pycache__/*.pyc` files, so every commit that ran
the test suite carried dozens of modified binaries and buried the real
diff. They are build output: regenerated on any run, machine- and
interpreter-specific (both cpython-312 and -313 copies were committed),
and never useful to a reader.

`git rm --cached` drops them from the index while leaving them on disk,
so nothing needs rebuilding locally.

Also adds a .gitignore, which the repo had been missing entirely --
covering caches, build output, virtualenvs, and `.unstract.toml`, which
is a local config file that can hold credentials.
Work in progress that predates the gotcha-fix pass; committing it as its
own change so the fixes that follow are reviewable in isolation.

- `config doctor` reports where each setting resolves from (flag, env
  var, profile literal, or profile `env:` reference), which answers the
  question that costs the most time: "the CLI says the key is missing,
  but I set it -- where is it looking?" Backed by
  `ResolvedConfig.resolution_source`, which reports the winning source
  without echoing the secret.
- `--discover` gains the navigable-groups overview: a ~1k-token map of
  the command tree with per-group counts and the exact `drill` command
  for each subtree, so an agent reads the map and follows one drill
  rather than pulling all 156 commands into context.
- `PollSpec.status_field` accepts a tuple of candidate field names, so a
  poll can match a status endpoint whose body spells the state
  differently from the run response.
- Drops the unused `typer` dependency and documents why each remaining
  one is there. `uv.lock` is committed so installs are reproducible and
  hash-pinned.
The CLI runs Click with `standalone_mode=False` so that Click's own parse
errors reach us and can be rendered through the structured error envelope
(SPEC §5.5). That mode also changes how `ctx.exit(code)` behaves: instead
of raising SystemExit, Click *returns* the code from the invocation.
`main()` ignored the return value and unconditionally returned 0.

So every documented exit code -- 2 usage, 3 auth, 4 not-found, 7 timeout,
9 already-consumed (SPEC §5.4) -- was reported to the shell as success. A
validation error printed `"exit_code": 2` inside its own error envelope
while the process exited 0. `set -e` never tripped, `cmd || handle` never
fired, and an agent branching on the status code saw every failure as a
pass. This defeats the exit-code contract the README advertises as a core
promise for agent use.

Return the invocation's result when it is an int (a command that falls
off the end returns None, which is success).

Tested by subprocess, which is the only way to catch this: Click's
`CliRunner` invokes with `standalone_mode=True` and never runs `main()`,
so the existing suite asserted the right contract against a code path the
real binary does not take -- 213 tests passed throughout.
Fixes for the issues hit while driving a document through the CLI end to
end (extract, Prompt Studio, export tool, deploy API, run). Root causes
were traced in the backend source rather than inferred, which changed the
fix in two cases and ruled one out entirely.

`prompt create --profile-manager`. `fetch_response` resolves a prompt's
LLM profile from the prompt's own `profile_manager` field and never falls
back to the project default -- unlike `index_document` and `single_pass`,
which both call `get_default_llm_profile(tool)`. A prompt created without
one is unrunnable, and fails with "Default LLM profile is not configured",
which names the *project* default -- genuinely set, and never read. The
flag makes prompts runnable from birth; the 500 now carries a hint naming
the real fix instead of sending the reader back to `profile set-default`.

`prompt-studio --challenge-llm` / `--monitor-llm`. A deployed run ended in
`ERROR` with a 422 at the very last step. The exported tool's metadata
carries `challenge_llm: ""`, which fails schema validation: the property
is `adapterType: LLM`, so the schema gains an `enum` of real adapter ids
and "" is not among them. (Not the `required` rule, as it first appears --
a present-but-empty key satisfies `required`; verified by running the
validator.) Export already resolves a valid value, so setting it on the
project before `export-tool` keeps the metadata non-empty.

`Param.mirror_as` for the API-key routes. `api-deployment key create`
needed both `--api-id` (URL) and `--api` (body) -- the same value twice.
The path value is now mirrored into the body under its own name, so
`--api-id` alone works. Same fix for the `pipeline key create` sibling.
Both records carry `doc_conflict` so the docs-diff skill does not restore
the flags; the diff also now recognises a mirrored param as present.

`default_from` fallback chains. `deployment` and `hitl` `org_id` fall back
to `platform.org_id` -- the same organization, in a block that starts
empty. Credentials deliberately do not chain: reusing a key across API
groups would be credential confusion, not convenience.

`--wait` on `index-document`, which returns the same `{task_id, status}`
shape as its siblings and was the only async command without it. Indexing
writes no Output Manager row, so the terminal status is the result and
there is no retrieve step.

Not fixed, deliberately: making `--vector-store`/`--embedding-model`
optional at `--chunk-size 0`. Both columns are NOT NULL server-side and
the serializer derives required=True, so relaxing the CLI would trade a
fast local exit-2 for a slower remote 400 -- moving the friction, not
removing it. `RequiredUnless` is kept as a tested primitive for a case
where the server genuinely accepts the omission. Help text now explains
that the values are stored but never queried.

Remaining items are server-side and cannot be fixed here; their help text
now describes the real behaviour instead of implying a workaround exists:
the registry's missing back-reference to the Prompt Studio tool, the
absent DELETE route, `adapter-choices` (routed to a method that does not
exist anywhere in the backend, so it 500s on every call), and the empty
default-triad response meaning "unset" rather than an error.
Additive reconciliation of SPEC, README and the update-unstract-cli
Skill against the fixes in the previous commit. Nothing here contradicts
what was already written -- the gaps were mechanisms the docs did not yet
mention.

SKILL.md matters most: it is what a future skill run reads to learn which
encodings exist, so a gap causes real errors later. It now covers
`mirror_as`, the `default_from` fallback chain, and `RequiredUnless`
(listed as available but explicitly *unused*, with the reason, so it is
not reached for where the server enforces the field anyway). The
`doc_conflict` guidance gains the key-create case as a second worked
example beside whisper-detail, so the deliberately dropped `api` and
`pipeline` flags are not "restored" on a later sync.

SPEC §7.1 generalises the mirroring sentence and adds the fallback chain
and the conditional constraint; §8.3/§8.5 record that `doc_conflict` now
suppresses param drift too, and that a mirrored parameter is not drift.
README gains one line: the org_id fallback, in the resolution-order
section. Per-endpoint flags stay out of it -- below its altitude.

The exit-code fix needs no doc change: SPEC §5.4 and the README table
always specified the correct behaviour, and the fix aligned the
implementation to them. A spec describes the contract, not its
regressions.
The build-sequence plan has served its purpose now that the CLI is
implemented; SPEC.md remains the living specification. Removing it leaves
six dangling citations, all fixed here so nothing points at a file that
no longer exists:

- README dropped the link to it from the closing "see also" line.
- pyproject, model.py (x3) and output.py cited section labels (R3, §1,
  §2, M1.6) that existed only in the plan. The surrounding prose stands
  on its own, so the parentheticals are simply removed -- the P1-P12
  pattern framework they referenced is defined inline in model.py's own
  docstring, so nothing is lost.
- test_model.py's module docstring opened by quoting the plan ("§2 calls
  this the load-bearing design work"); reworded to state the point
  directly.

Verified: `grep -rn IMPLEMENTATION_PLAN` returns nothing. Tests, ruff and
mypy all green.
A human-runnable end-to-end scenario for the `unstract` CLI: extract from
an invoice, build a Prompt Studio project, export it as a tool, deploy an
API, and run it -- with setup (temp config) and teardown (delete created
resources) steps and a summary-report checklist. This is the same flow
whose friction was captured in the recent gotcha-fix pass.
The install step ran `uv pip install --system -e ".[dev]"`, which targets
the runner's `/usr` interpreter. `setup-uv` with `python-version` also
provisions a project venv and exports `VIRTUAL_ENV`, and that interpreter
is externally managed (PEP 668), so `--system` aborts with "externally
managed environment" before any test runs.

Switch to the project workflow the README documents for CI:
`uv sync --frozen --extra dev` installs the exact hash-pinned tree from
uv.lock into the venv (and fails if the lock is stale), and every step
runs through `uv run`. Reproduced green locally: sync, ruff, mypy, pytest
(245), and all three `--discover` index checks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant