Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
40 changes: 32 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ JSON back. It also clones one organization's resources into another.

```bash
curl -LsSf https://raw.githubusercontent.com/Zipstack/unstract-cli/main/install.sh | sh
unstract config init
unstract config doctor
export UNSTRACT_PLATFORM_KEY=...
unstract auth whoami # resolves and stores your organisation
unstract docstudio deployment ls # what can I run?
```

The installer fetches `uv` if it is missing and installs the CLI with it; `uv`
Expand All @@ -16,6 +17,11 @@ brings its own Python, so nothing on the machine has to match. Already have
thing. Set `UNSTRACT_CLI_SOURCE` to install a branch or a local checkout
instead.

`auth whoami` is the shortest way in: a platform key carries the organisation it
belongs to, so supplying the key is enough to discover `org_id` rather than
reading it out of a web-app URL. `config init` and `config doctor` are still
there for a profile you write by hand.

Or run it without installing: `uvx --from git+https://github.com/Zipstack/unstract-cli unstract --discover groups`.

## Output
Expand Down Expand Up @@ -70,8 +76,8 @@ search, or `$UNSTRACT_CONFIG`, or `--config`. Every setting resolves
**flag > env > profile > built-in default**, and the CLI is fully usable with no
config file at all. The flag tier is the connection options on each product
group — `unstract docstudio --base-url … --org-id … deployment run …`, and
`--base-url`/`--api-key` on `whisper` — which override the profile for that one
invocation without writing anything.
`--base-url`/`--api-key` on `whisper` and on `auth` — which override the profile
for that one invocation without writing anything.

```toml
default_profile = "cloud-us"
Expand All @@ -85,6 +91,9 @@ base_url = "https://us-central.unstract.com"
org_id = "org_ABC123"
api_key = "env:UNSTRACT_DEPLOYMENT_KEY"

[profiles.cloud-us.platform]
base_url = "https://us-central.unstract.com"

[profiles.cloud-us.deployments.invoices]
api_name = "invoice-parser"
```
Expand All @@ -96,9 +105,23 @@ own `api_key` when its deployment has a separate key of its own.

Get an LLMWhisperer key from the LLMWhisperer console; a deployment key is shown
on the API deployment's own page in the Unstract UI, and an organisation-wide one
under Settings → API Key Manager. `config init` also writes an
`onprem-example` profile as a shape to copy for a self-hosted install — its host
is a placeholder, and only the *active* profile is ever resolved.
under Settings → API Key Manager. A **platform key** is minted by an
organisation admin under Settings → Platform API Keys. `config init` also writes
an `onprem-example` profile as a shape to copy for a self-hosted install — its
host is a placeholder, and only the *active* profile is ever resolved.

The two Unstract keys are not interchangeable and neither replaces the other. A
deployment key runs deployments and cannot say which organisation it belongs
to; a platform key identifies the organisation and lists what is in it, and
cannot run a deployment. `auth whoami` and `deployment ls` take the platform key;
`deployment run` and `deployment status` take the deployment key; `auth whoami`
and `deployment ls` take the platform key. `org_id` lives on the `docstudio`
block either way — `auth whoami` writes the one it resolves there, because that
is where everything that needs it reads from.

`config init` deliberately leaves `platform.api_key` out of the block above, so
that a caller who only holds a deployment key is not told a platform key is
missing. Add the line, or set `$UNSTRACT_PLATFORM_KEY`, when you have one.

A credential can be written into the file literally, but `env:VAR_NAME`
indirection is what `config init` writes and what the examples use: the file
Expand Down Expand Up @@ -128,7 +151,8 @@ not part of the document-processing path the rest of this CLI wraps, so an agent
serving a user request should not reach for it unasked. It talks to two
deployments at once, which no single profile describes, so it takes both
endpoints as flags and both keys from `UNSTRACT_SRC_PLATFORM_KEY` /
`UNSTRACT_TGT_PLATFORM_KEY`. It exits 0 when nothing failed, which is not the
`UNSTRACT_TGT_PLATFORM_KEY` — two keys for two organisations, so it reads neither
the `platform` profile block nor `$UNSTRACT_PLATFORM_KEY`. It exits 0 when nothing failed, which is not the
same as everything having moved: oversize and unsupported documents are skipped
by design, and `data.skipped` counts them.

Expand Down
38 changes: 34 additions & 4 deletions src/unstract_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from unstract_cli.config import (
DOCSTUDIO,
LLMWHISPERER,
PLATFORM,
ConfigError,
ResolvedConfig,
load_config,
Expand Down Expand Up @@ -84,7 +85,7 @@ def override(self, product: str, values: dict[str, Any]) -> None:
def secrets(self) -> list[str]:
"""Resolved credentials, for scrubbing anything on its way to a stream."""
out: list[str] = []
for product in (LLMWHISPERER, DOCSTUDIO):
for product in (LLMWHISPERER, DOCSTUDIO, PLATFORM):
try:
if value := self.config.get(product, "api_key"):
out.append(str(value))
Expand Down Expand Up @@ -217,8 +218,9 @@ def whisper_group(ctx: Context, **overrides: str | None) -> None:
"--transport-timeout",
type=float,
default=None,
help="Seconds before a stalled connection is given up on. Unset means it "
"is not, which is what the client has always done.",
help="Seconds before a stalled connection is given up on. Unset means no "
"bound for `deployment run` and `status`, and the platform client's own "
"60s default for `deployment ls`.",
)
@pass_context
def docstudio_group(
Expand All @@ -234,11 +236,39 @@ def deployment_group() -> None:
"""Work with a deployed API."""


@cli.group("auth")
@_connection_options()
@click.option(
"--transport-timeout",
type=float,
default=None,
help="Seconds before a stalled connection is given up on. Unset means the "
"client's own default, which is 60.",
)
@pass_context
def auth_group(
ctx: Context, transport_timeout: float | None, **overrides: str | None
) -> None:
"""Identify the credential you are using.

Its flags configure the platform key, which is the credential that knows
which organisation it belongs to. A deployment key does not: it authenticates
against the deployment it was minted for and never reaches this endpoint.
"""
ctx.transport_timeout = transport_timeout
ctx.override(PLATFORM, overrides)


cli.add_command(config_group)

# Imported for their side effect of registering commands, and imported last
# because those modules hang their commands off the groups declared just above.
from unstract_cli.commands import clone_cmd, docstudio_cmd, whisper_cmd # noqa: E402,F401
from unstract_cli.commands import ( # noqa: E402,F401
clone_cmd,
docstudio_cmd,
platform_cmd,
whisper_cmd,
)


def command_tree() -> dict[str, Any]:
Expand Down
36 changes: 32 additions & 4 deletions src/unstract_cli/commands/config_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
DOCSTUDIO,
KEY_SOURCES,
LLMWHISPERER,
PLATFORM,
PRODUCTS,
ConfigError,
ConfigFile,
Expand All @@ -36,6 +37,7 @@
emit_result,
resolve_format,
)
from unstract_cli.core.platform import platform_client

#: Keys whose value is never echoed back, even on explicit request: this output
#: is as likely to land in a log or a transcript as on a screen.
Expand Down Expand Up @@ -208,10 +210,11 @@ def _probe(resolved: ResolvedConfig) -> dict[str, Any]:
"""Check each product's credentials against the service, where that is possible.

LLMWhisperer has a read-only usage endpoint, so its key can be verified for
real. A deployment has no side-effect-free endpoint -- the only thing to call
is an execution -- so its entry reports that the settings resolve and says
plainly that nothing was verified. Claiming otherwise would be worse than
not checking.
real, and so does the platform API -- `whoami` reads nothing but the key
itself. A deployment has no side-effect-free endpoint -- the only thing to
call is an execution -- so its entry reports that the settings resolve and
says plainly that nothing was verified. Claiming otherwise would be worse
than not checking.
"""
out: dict[str, Any] = {}
try:
Expand All @@ -233,6 +236,31 @@ def _probe(resolved: ResolvedConfig) -> dict[str, Any]:
"detail": "The key was accepted by the usage endpoint.",
}

try:
with translated(endpoint="whoami"):
identity = platform_client(resolved).whoami()
except CLIError as exc:
out[PLATFORM] = {
"checked": True,
"ok": False,
"detail": exc.message,
"exit_code": int(exc.exit_code),
}
except ConfigError as exc:
# Null, not False: a platform key is optional -- a caller holding only a
# deployment key is the common case -- so an absent one is a report
# rather than a failure, and must not decide this command's exit code.
out[PLATFORM] = {"checked": False, "ok": None, "detail": str(exc)}
else:
out[PLATFORM] = {
"checked": True,
"ok": True,
# The organisation is the reason to hold this key, so the probe
# reports which one answered rather than only that one did.
"organization_id": identity.get("organization_id"),
"detail": "The key was accepted, and resolved to an organisation.",
}

resolves = all(
resolved.get(DOCSTUDIO, key) for key in ("org_id", "api_key", "base_url")
)
Expand Down
Loading
Loading