diff --git a/README.md b/README.md
index 1a1589c..a60782d 100644
--- a/README.md
+++ b/README.md
@@ -1,60 +1,134 @@
# unstract-cli
-`unstract` — one CLI for the Unstract suite: extract a document with
-LLMWhisperer, run it through a Document Studio API deployment, get structured
-JSON back. It also clones one organization's resources into another.
+`unstract` runs [LLMWhisperer](https://docs.unstract.com/llmwhisperer/) text
+extraction and [Unstract](https://docs.unstract.com/unstract/) API deployments
+from the terminal. Pass `-o json` and every command prints one JSON envelope, so a
+shell script or a coding agent can drive it.
+
+Full reference:
+
+## Install
```bash
curl -LsSf https://raw.githubusercontent.com/Zipstack/unstract-cli/main/install.sh | sh
-unstract auth login # asks for your keys, checks them, stores them
-unstract docstudio deployment ls # what can I run?
```
-For an agent or CI, no prompts and no file — the environment is the profile:
+The installer fetches [`uv`](https://docs.astral.sh/uv/) if it is missing and
+installs the CLI with it; `uv` brings its own Python. With `uv` or `pip`
+already there:
+
+```bash
+uv tool install unstract-cli # or: pip install unstract-cli
+unstract --version
+```
+
+## Get your keys
+
+| Key | Where it is minted | What it does |
+| --- | --- | --- |
+| **Platform key** | An organisation admin, under **Settings → Platform API Keys** in the Unstract UI | Platform related operations and to identify the organization |
+| **Deployment key** | The API deployment's own page in the Unstract UI or an organisation admin mints one under **Settings → Global API Deployment Keys** | Runs deployments (`deployment run`, `deployment status`) |
+| **LLMWhisperer key** | The LLMWhisperer console | Extracts text (`whisper …`) |
+
+## Set up
+
+```bash
+unstract auth login
+```
+
+A wizard asks for each product's URL (Enter keeps the cloud host; self-hosted,
+type your own) and API keys, then writes `~/.unstract/config.toml`. Then verify:
```bash
-export UNSTRACT_ORG_ID=... UNSTRACT_DEPLOYMENT_KEY=... LLMWHISPERER_API_KEY=...
-unstract -o json whisper extract ./doc.pdf
-unstract -o json docstudio deployment run invoice-parser ./doc.pdf
+unstract auth whoami # which organisation the platform key belongs to
+unstract config doctor --probe # where each setting resolved from, keys checked
```
-The installer fetches `uv` if it is missing and installs the CLI with it; `uv`
-brings its own Python, so nothing on the machine has to match. Already have
-`uv`? `uv tool install git+https://github.com/Zipstack/unstract-cli` is the same
-thing. Set `UNSTRACT_CLI_SOURCE` to install a branch or a local checkout
-instead.
+## Configuration
-Or run it without installing: `uvx --from git+https://github.com/Zipstack/unstract-cli unstract --discover groups`.
+`~/.unstract/config.toml`, or `$UNSTRACT_CONFIG`, or `--config`, or a
+project-local `.unstract.toml` is found by upward search. Here's an example config that uses environment variables for the API keys.
-## Output
+```toml
+default_profile = "cloud-us"
+
+[profiles.cloud-us.docstudio]
+base_url = "https://us-central.unstract.com"
+org_id = "org_ABC123"
+platform_key = "env:UNSTRACT_PLATFORM_KEY"
+api_key = "env:UNSTRACT_DEPLOYMENT_KEY"
+
+[profiles.cloud-us.llmwhisperer]
+base_url = "https://llmwhisperer-api.us-central.unstract.com/api/v2"
+api_key = "env:LLMWHISPERER_API_KEY"
+
+# Only for a deployment whose key differs from the profile's.
+[profiles.cloud-us.deployments."invoice-parser"]
+api_key = "env:INVOICE_PARSER_KEY"
+```
+
+Every setting resolves **flag > environment > profile > built-in default**.
+`auth login` writes keys literally; `env:VAR_NAME` keeps them out of the file.
+The connection flags on each group (`--base-url`, `--api-key`, `--org-id`,
+`--platform-key`) override the profile for one invocation without writing
+anything. `config doctor` reports where each setting resolved from without
+echoing a value, and exits non-zero when one of its checks fails.
+
+### Environment variables
+
+The same settings without a file, for CI, containers and agents:
+
+```bash
+export UNSTRACT_PLATFORM_KEY=... # auth whoami, deployment ls
+export UNSTRACT_DEPLOYMENT_KEY=... # deployment run / status
+export UNSTRACT_ORG_ID=... # the organisation id auth whoami reports
+export LLMWHISPERER_API_KEY=... # whisper …
+export UNSTRACT_BASE_URL=... # self-hosted only
+export LLMWHISPERER_BASE_URL=... # self-hosted only
+```
-`unstract` prints a table by default — in a terminal and in a pipe alike, so
-what you see while trying something is what a script sees running it.
+`auth login` also takes each key as a flag (`--platform-key`, `--deployment-key`,
+`--llmwhisperer-key`; `-` reads it from stdin) and the host as `--base-url`, so
+it runs without a terminal too.
+
+## Usage
+
+```bash
+# Extract text from a document (path or URL); waits for the result
+unstract whisper extract invoice.pdf -o raw > invoice.txt
+
+# What deployments can I run?
+unstract docstudio deployment ls
+
+# Run one and wait for the structured result
+unstract docstudio deployment run invoice-parser invoice.pdf
+
+# Long job: submit, then check later
+unstract docstudio deployment run invoice-parser invoice.pdf --no-wait
+unstract docstudio deployment status invoice-parser
+```
+
+`--help` on any command lists its options; `unstract --discover full` prints
+the whole command tree, every flag and the exit-code table as JSON.
+
+> **Note:** `clone` moves one organisation's resources into another using two
+> admin platform keys, `UNSTRACT_SRC_PLATFORM_KEY` and `UNSTRACT_TGT_PLATFORM_KEY`.
+
+## Output for scripts and agents
**Parsing anything? Pass `-o json`.** stdout then carries exactly one envelope,
-on success and on failure alike:
+on success and on failure alike, and diagnostics go to stderr:
```json
{"ok": true, "data": {...}, "error": null, "meta": {"contract_version": 1}}
```
-`-o json` output depends on nothing but the command and its arguments — not the
-terminal, not the config, not the environment. `-o raw` prints one field
-unwrapped, for piping a document's text somewhere else. Diagnostics, warnings
-and progress always go to stderr.
-
-Consuming the JSON: ignore fields you do not recognise, and refuse a
-`meta.contract_version` above the one you were written against. `unstract
---discover full` publishes the whole contract alongside every command and flag.
+Ignore fields you do not recognise; refuse a `meta.contract_version` above the
+one you were written against. `-o raw` prints one field unwrapped. When a
+coding agent is driving (detected from the environment it sets) json is the
+default; `--agent yes|no` forces that, and an explicit `-o` wins over both.
-If a coding agent is driving (detected from the environment it sets), the
-*default* becomes json. `--agent yes|no` forces that either way, and an explicit
-`-o` always wins over both.
-
-Failures exit non-zero with a stable code. The codes are this CLI's own
-convention, not a service's — they are the `ExitCode` enum in
-`core/errors.py`, and `--discover full` publishes the table so a caller does not
-have to copy it:
+Failures exit non-zero with a stable code:
| Code | Meaning |
|------|---------|
@@ -71,111 +145,10 @@ have to copy it:
| 10 | the result was read but could not be saved — it is in `error.details` |
| 130 | interrupted (128 + SIGINT) — the user stopped it, not a failure |
-## Credentials
-
-Three keys, each for one job:
-
-- **LLMWhisperer key** — extracts text (`whisper …`). Minted in the LLMWhisperer
- console.
-- **Deployment key** — runs deployments (`deployment run`, `deployment status`).
- Shown on the API deployment's own page in the Unstract UI; one an
- organisation admin mints under **Settings → Global API Deployment Keys**
- covers every deployment in the organisation.
-- **Platform key** — identifies the organisation and lists what is in it
- (`auth whoami`, `deployment ls`). Minted by an organisation admin under
- **Settings → Platform API Keys**.
-
-`auth login` takes whichever of the three you have, checks the two it can
-(`whoami` for the platform key, the usage endpoint for the LLMWhisperer key; a
-deployment key has nothing side-effect-free to call and is stored as given) and
-writes them to one profile. Run it again to rotate a key. A login that stores a
-different host drops the profile's other keys rather than leave them beside a
-server that never accepted them: it asks first, or without a terminal fails
-until `--force`. Without a terminal pass them as flags — `--platform-key`, `--deployment-key`, `--llmwhisperer-key`,
-any one of them `-` to read from stdin.
-
-## Configuration
-
-`~/.unstract/config.toml`, or a project-local `.unstract.toml` found by upward
-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 — `--base-url`, `--api-key`, `--org-id` and `--platform-key` on
-`docstudio`, `--base-url`/`--api-key` on `whisper`, `--base-url`/`--platform-key`
-on `auth` — which override the profile for that one invocation without writing
-anything.
-
-```toml
-default_profile = "cloud-us"
-
-[profiles.cloud-us.llmwhisperer]
-base_url = "https://llmwhisperer-api.us-central.unstract.com/api/v2"
-api_key = "env:LLMWHISPERER_API_KEY"
-
-[profiles.cloud-us.docstudio]
-base_url = "https://us-central.unstract.com"
-org_id = "org_ABC123"
-api_key = "env:UNSTRACT_DEPLOYMENT_KEY"
-platform_key = "env:UNSTRACT_PLATFORM_KEY"
-
-# Only for a deployment whose key differs from the one above.
-[profiles.cloud-us.deployments."invoice-parser"]
-api_key = "env:INVOICE_PARSER_KEY"
-```
-
-`deployment run` and `deployment status` take the API name as `deployment ls`
-prints it. `ls` itself authenticates with the platform key and refuses
-`--api-key`, which on `docstudio` means a deployment key. The key for a run resolves `--api-key` > `$UNSTRACT_DEPLOYMENT_KEY` >
-the deployment's own entry > the profile's `api_key`, so most profiles need no
-`deployments` section at all; `config set docstudio api_key --deployment
-` writes one. `org_id` lives on the `docstudio` block — `auth login`
-and `auth whoami` write the one the platform key resolves there. `config init`
-writes this shape minus `platform_key` and the `deployments` entry — both are
-the exception, not the starting point — plus a `cloud-eu` profile and an
-`onprem-example` shape to copy for a self-hosted install; only the *active*
-profile is ever resolved.
-
-Either form works for a credential. `auth login` writes keys literally, having
-checked them at the moment it writes. `env:VAR_NAME` indirection — what `config
-init` writes and what the example above uses — keeps the secret out of the file,
-so it stays safe to copy or commit; that is the form for a shared machine or a
-CI checkout. Either way the file is created `0600`, and `config doctor` warns
-when its mode is wider than that.
-
-`unstract config doctor` reports where each setting resolved from — including
-whether an `env:` reference is actually set in the current process — without
-echoing any value. `--probe` also checks the two keys that can be
-checked — the platform key and the LLMWhisperer key, the same two `auth login`
-checks — and, with a platform key, warns about a `deployments` entry the
-organisation no longer has. It exits non-zero when one of its own checks failed, so a setup script can
-branch on it.
-
-A project-local `.unstract.toml` **found by upward search** may not supply a
-key or `base_url`. Those are ignored, with a warning; everything else in it —
-profile selection, `org_id` — applies as usual. A checkout you did not write is
-not trusted to name the host your key is sent to. Name the file explicitly
-(`--config` or `$UNSTRACT_CONFIG`) and it is honoured in full.
-
-What that protects is the key and the host, not the routing: `org_id` and
-profile selection stay repo-controllable by design, so a project file can still
-decide *which* organisation a command runs against on a host you trust. Read
-one before you run inside a checkout you did not write.
-
-`clone` is the exception, and it is an operator command: a human moving one
-organisation's resources into another, holding two admin Platform keys. It is
-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` — two keys for two organisations, so it reads
-neither the profile's `platform_key` 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.
-
## Development
```bash
-uv venv && uv pip install -e '.[dev]'
-uv run pytest # offline; no network, no credentials
+uv sync --extra dev
+uv run pytest
uv run ruff check .
```
diff --git a/install.sh b/install.sh
index fdd8906..d5c24d9 100755
--- a/install.sh
+++ b/install.sh
@@ -4,8 +4,7 @@
# UNSTRACT_CLI_SOURCE=/path/to/checkout sh install.sh
set -eu
-# Flips to the bare PyPI name once the CLI is published there.
-SOURCE="${UNSTRACT_CLI_SOURCE:-git+https://github.com/Zipstack/unstract-cli@main}"
+SOURCE="${UNSTRACT_CLI_SOURCE:-unstract-cli}"
if ! command -v uv >/dev/null 2>&1; then
echo "Installing uv..." >&2
@@ -22,12 +21,13 @@ if ! command -v uv >/dev/null 2>&1; then
fi
# uv fetches its own interpreter, so the CLI's Python floor is not the user's problem.
-uv tool install --force "$SOURCE"
+# A pre-release is taken only while no stable release exists.
+uv tool install --force --prerelease if-necessary "$SOURCE"
if command -v unstract >/dev/null 2>&1; then
echo
unstract --version 2>/dev/null || true
- echo "Run 'unstract config init' to get started." >&2
+ echo "Run 'unstract auth login' to get started." >&2
exit 0
fi
diff --git a/pyproject.toml b/pyproject.toml
index 9756245..903875e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,6 +23,13 @@ dependencies = [
"requests>=2.32.3",
]
+[project.urls]
+Homepage = "https://unstract.com"
+Documentation = "https://docs.unstract.com/unstract/unstract_platform/cli/unstract_cli/"
+Repository = "https://github.com/Zipstack/unstract-cli"
+Issues = "https://github.com/Zipstack/unstract-cli/issues"
+Changelog = "https://github.com/Zipstack/unstract-cli/releases"
+
[project.optional-dependencies]
dev = [
"pytest>=8.0",
diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py
index 7375182..24ebc84 100644
--- a/src/unstract_cli/commands/config_cmd.py
+++ b/src/unstract_cli/commands/config_cmd.py
@@ -119,6 +119,23 @@ def config_init(obj: Any, force: bool) -> None:
default_profile="cloud-us", profiles=starter_profiles(), path=path, exists=True
)
written, meta = _saved(new, path)
+ variables = sorted(
+ {
+ value.removeprefix("env:")
+ for profile in new.profiles.values()
+ for block in profile.values()
+ for value in block.values()
+ if isinstance(value, str) and value.startswith("env:")
+ }
+ )
+ diagnostic(
+ f"Wrote {written}.\n"
+ f"Next: export {' and '.join(variables)}, or run `unstract auth login` "
+ "to store keys in the file instead.\n"
+ "Then `unstract config doctor` shows what resolved.",
+ quiet=getattr(obj, "quiet", False),
+ verbosity=getattr(obj, "verbosity", 0),
+ )
emit_result(
{
"created": str(written),
diff --git a/src/unstract_cli/commands/platform_cmd.py b/src/unstract_cli/commands/platform_cmd.py
index 3a1f249..776e348 100644
--- a/src/unstract_cli/commands/platform_cmd.py
+++ b/src/unstract_cli/commands/platform_cmd.py
@@ -172,6 +172,24 @@ def _keys_from_flags(given: dict[str, str | None]) -> dict[str, str | None]:
return keys
+def _url(value: str) -> str:
+ parts = urlsplit(value.strip())
+ if parts.scheme not in ("http", "https") or not parts.netloc:
+ raise click.UsageError(f"{value.strip()!r} is not an http(s) URL.")
+ return value.strip()
+
+
+def _hosts_from_prompts(resolved: ResolvedConfig) -> dict[str, str]:
+ """One visible prompt per product; only a host typed over the one shown counts."""
+ hosts: dict[str, str] = {}
+ for product in (DOCSTUDIO, LLMWHISPERER):
+ shown = resolved.get(product, "base_url")
+ typed = _prompt(f"{product} base URL", default=shown, value_proc=_url)
+ if _host(typed) != _host(shown):
+ hosts[f"{product}.base_url"] = typed
+ return hosts
+
+
def _keys_from_prompts() -> dict[str, str | None]:
"""One hidden, skippable prompt per credential, in the documented order."""
keys: dict[str, str | None] = {}
@@ -189,7 +207,11 @@ def _keys_from_prompts() -> dict[str, str | None]:
def _validation_config(
- ctx: Context, cfg: ConfigFile, name: str, keys: dict[str, str | None]
+ ctx: Context,
+ cfg: ConfigFile,
+ name: str,
+ keys: dict[str, str | None],
+ hosts: dict[str, str] | None = None,
) -> ResolvedConfig:
"""The keys being stored, resolved as the profile they will land in.
@@ -198,7 +220,7 @@ def _validation_config(
profile that does not exist yet resolves against nothing but the flags and
the environment, so a stranger's host cannot be the one that answers.
"""
- overrides = dict(ctx.overrides)
+ overrides = {**ctx.overrides, **(hosts or {})}
for credential, _flag, _label, (product, key) in _CREDENTIALS:
if keys.get(credential):
overrides[f"{product}.{key}"] = keys[credential]
@@ -310,9 +332,11 @@ def _new_profile_name(cfg: ConfigFile, org_id: str, org_name: Any) -> str:
def login(ctx: Context, profile: str | None, force: bool, **given: str | None) -> None:
"""Store your keys in a profile, checking each one that can be checked.
- At a terminal this asks for each key in turn -- platform, deployment,
- LLMWhisperer -- and Enter skips one; at least one is needed. Without a
- terminal, pass the keys as flags, one of them as `-` to read it from stdin.
+ At a terminal this first asks for each product's host, Enter keeping the
+ one shown, then for each key in turn -- platform, deployment, LLMWhisperer
+ -- and Enter skips one; at least one is needed. Without a terminal, pass
+ the keys as flags, one of them as `-` to read it from stdin, and the host
+ as `--base-url` if it is not the default.
\b
Examples:
@@ -333,13 +357,8 @@ def login(ctx: Context, profile: str | None, force: bool, **given: str | None) -
"""
if given["platform"] is None:
given["platform"] = ctx.overrides.get(f"{DOCSTUDIO}.platform_key")
- if any(value is not None for value in given.values()):
- keys = _keys_from_flags(given)
- interactive = False
- elif _interactive():
- keys = _keys_from_prompts()
- interactive = True
- else:
+ interactive = not any(value is not None for value in given.values())
+ if interactive and not _interactive():
raise CLIError(
"No keys were given and stdin is not a terminal, so there is nothing "
"to ask for them with.",
@@ -347,14 +366,6 @@ def login(ctx: Context, profile: str | None, force: bool, **given: str | None) -
hint="Pass --platform-key, --deployment-key or --llmwhisperer-key, as a "
"value or as `-` to read one of them from stdin.",
)
- if not any(keys.values()):
- raise CLIError(
- "No key was given; at least one is needed.", ExitCode.USAGE, hint=KEY_SOURCES
- )
- # A key given here is never read back through the config layer that
- # registers one, so nothing else would scrub it out of an error payload.
- for value in keys.values():
- remember_secret(value)
try:
cfg = _writable_config()
@@ -366,7 +377,26 @@ def login(ctx: Context, profile: str | None, force: bool, **given: str | None) -
except ConfigError as exc:
raise CLIError(str(exc), ExitCode.USAGE) from exc
- resolved = _validation_config(ctx, cfg, name, keys)
+ hosts: dict[str, str] = {}
+ if interactive:
+ click.echo(
+ "Welcome to Unstract -- let's connect this machine to your organisation.",
+ err=True,
+ )
+ hosts = _hosts_from_prompts(_validation_config(ctx, cfg, name, {}))
+ keys = _keys_from_prompts()
+ else:
+ keys = _keys_from_flags(given)
+ if not any(keys.values()):
+ raise CLIError(
+ "No key was given; at least one is needed.", ExitCode.USAGE, hint=KEY_SOURCES
+ )
+ # A key given here is never read back through the config layer that
+ # registers one, so nothing else would scrub it out of an error payload.
+ for value in keys.values():
+ remember_secret(value)
+
+ resolved = _validation_config(ctx, cfg, name, keys, hosts)
timeout = getattr(ctx, "transport_timeout", None)
result: dict[str, Any] = {"profile": name, "path": None}
identity: dict[str, Any] = {}
@@ -464,6 +494,9 @@ def login(ctx: Context, profile: str | None, force: bool, **given: str | None) -
# An entry left with no key is still listed as a deployment the profile holds.
if path[0] == "deployments" and not table:
block["deployments"].pop(path[1], None)
+ for product in PRODUCTS:
+ if f"{product}.base_url" in hosts:
+ block.setdefault(product, {})["base_url"] = hosts[f"{product}.base_url"]
noticed: set[str] = set()
for credential, _flag, _label, (product, key) in _CREDENTIALS:
if not keys[credential]:
diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py
index bff7572..a174239 100644
--- a/src/unstract_cli/config.py
+++ b/src/unstract_cli/config.py
@@ -537,6 +537,8 @@ def require(self, product: str, key: str) -> Any:
# lands in shell history and in the process list.
if key not in SECRET_SETTINGS:
hints.append(f"or pass --{key.replace('_', '-')}")
+ if not self.file.exists:
+ hints.append("or run `unstract auth login` to set up interactively")
raise ConfigError(
f"Missing required setting {product}.{key}. To fix: {'; '.join(hints)}."
)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 9da1c3d..a137f83 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -224,6 +224,17 @@ def test_init_refuses_to_clobber_without_force(capsys, tmp_path, monkeypatch):
assert run(capsys, "config", "init", "--force")[0] == 0
+def test_init_says_what_to_do_next_on_stderr(capsys, tmp_path, monkeypatch):
+ monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml"))
+
+ code, payload, err = run(capsys, "config", "init")
+
+ assert code == 0 and payload["ok"] is True
+ assert "export LLMWHISPERER_API_KEY and UNSTRACT_DEPLOYMENT_KEY" in err
+ assert "`unstract auth login`" in err
+ assert "config doctor" in err
+
+
def test_doctor_reports_sources_without_leaking_values(capsys, monkeypatch):
monkeypatch.setenv("LLMWHISPERER_API_KEY", "super-secret-value")
code, payload, _ = run(capsys, "config", "doctor")
diff --git a/tests/test_commands.py b/tests/test_commands.py
index bda41ad..f29d432 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -10,6 +10,8 @@
import json
import os
import socket
+import stat
+import sys
import click
import httpx
@@ -1576,6 +1578,13 @@ def test_whoami_reports_the_identity_the_service_returned(
assert envelope(out)["data"] == IDENTITY
+def test_a_fresh_install_is_pointed_at_login_rather_than_at_a_file(capsys):
+ code, _, err = run(capsys, "auth", "whoami")
+
+ assert code == int(ExitCode.USAGE)
+ assert "run `unstract auth login`" in err
+
+
def test_whoami_is_called_with_no_organisation(
capsys, platform_client, monkeypatch, tmp_path
):
@@ -2231,10 +2240,18 @@ def login_seams(monkeypatch, platform_client, tmp_path):
def prompt(text, **kwargs):
state["prompts"].append(text)
- return state["answers"].pop(0)
+ state["defaults"].append(kwargs.get("default"))
+ while True:
+ answer = state["answers"].pop(0)
+ if answer == "" and kwargs.get("default"):
+ answer = kwargs["default"]
+ try:
+ return kwargs.get("value_proc", lambda value: value)(answer)
+ except click.UsageError as exc:
+ click.echo(f"Error: {exc.message}", err=True)
def install(answers=(), *, confirm=False, tty=True, whoami=None, usage=None):
- state["answers"], state["prompts"] = list(answers), []
+ state["answers"], state["prompts"], state["defaults"] = list(answers), [], []
monkeypatch.setattr(platform_cmd, "_interactive", lambda: tty)
monkeypatch.setattr(platform_cmd, "_prompt", prompt)
confirms = list(confirm) if isinstance(confirm, list) else None
@@ -2274,15 +2291,19 @@ def _written(tmp_path) -> str:
def test_login_asks_for_each_key_in_turn_and_stores_them_as_literals(
capsys, login_seams, tmp_path
):
- """Interactive path: platform, deployment, LLMWhisperer, one hidden prompt
- each. The two keys with a read-only endpoint are checked; the deployment
- key is stored as given and said to be."""
- seams = login_seams([PK, DK, LK])
+ """Interactive path: a visible host prompt per product, then platform,
+ deployment, LLMWhisperer, one hidden prompt each. The two keys with a
+ read-only endpoint are checked; the deployment key is stored as given and
+ said to be."""
+ seams = login_seams(["", "", PK, DK, LK])
code, out, err = run(capsys, "auth", "login")
assert code == int(ExitCode.SUCCESS)
+ assert err.startswith("Welcome to Unstract")
assert [p.split(" (")[0] for p in seams["prompts"]] == [
+ "docstudio base URL",
+ "llmwhisperer base URL",
"Platform key",
"Deployment key",
"LLMWhisperer key",
@@ -2310,7 +2331,7 @@ def test_login_asks_for_each_key_in_turn_and_stores_them_as_literals(
def test_login_with_every_prompt_skipped_is_a_usage_error(capsys, login_seams, tmp_path):
- login_seams(["", "", ""])
+ login_seams(["", "", "", "", ""])
code, out, _ = run(capsys, "auth", "login")
@@ -2320,7 +2341,7 @@ def test_login_with_every_prompt_skipped_is_a_usage_error(capsys, login_seams, t
def test_login_with_only_a_deployment_key_calls_nothing(capsys, login_seams, tmp_path):
- seams = login_seams(["", DK, ""])
+ seams = login_seams(["", "", "", DK, ""])
code, out, _ = run(capsys, "auth", "login")
@@ -2411,15 +2432,33 @@ def test_login_reads_at_most_one_key_from_stdin(capsys, login_seams, tmp_path):
assert not (tmp_path / "config.toml").exists()
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_login_creates_the_config_file_and_its_directory_owner_only(
+ capsys, login_seams, tmp_path, monkeypatch
+):
+ """A fresh install has no file and no directory; login is the first thing
+ documented to run, so it must not need `config init` before it."""
+ path = tmp_path / "fresh" / ".unstract" / "config.toml"
+ monkeypatch.setenv("UNSTRACT_CONFIG", str(path))
+ login_seams([])
+
+ code, _, _ = run(capsys, "auth", "login", "--platform-key", PK)
+
+ assert code == int(ExitCode.SUCCESS)
+ assert stat.S_IMODE(path.stat().st_mode) == 0o600
+ assert f'platform_key = "{PK}"' in path.read_text(encoding="utf-8")
+
+
def test_login_takes_the_platform_key_from_the_group_flag_too(
capsys, login_seams, tmp_path
):
seams = login_seams([])
- code, _, _ = run(capsys, "auth", "--platform-key", PK, "login")
+ code, _, err = run(capsys, "auth", "--platform-key", PK, "login")
assert code == int(ExitCode.SUCCESS)
assert seams["prompts"] == []
+ assert "Welcome" not in err
assert f'platform_key = "{PK}"' in _written(tmp_path)
@@ -2442,7 +2481,7 @@ def test_login_writes_nothing_when_any_key_is_rejected(
if rejected == "llmwhisperer"
else None,
}
- login_seams([PK, DK, LK], **kwargs)
+ login_seams(["", "", PK, DK, LK], **kwargs)
code, out, _ = run(capsys, "auth", "login")
@@ -2456,9 +2495,9 @@ def test_login_again_replaces_the_keys_given_and_keeps_the_rest(
):
"""Rotation: the same profile, updated in place, and a same-organisation
re-run asks nothing."""
- login_seams([PK, DK, LK])
+ login_seams(["", "", PK, DK, LK])
run(capsys, "auth", "login")
- seams = login_seams(["", "dk-rotated-000001", ""])
+ seams = login_seams(["", "", "", "dk-rotated-000001", ""])
code, _, _ = run(capsys, "auth", "login")
@@ -2506,10 +2545,10 @@ def test_login_offers_a_new_profile_named_after_the_organisation(
):
"""Interactive: the profile the key belongs to is a new one, suggested
from the organisation's display name, and the old profile is untouched."""
- login_seams([PK, "", ""], whoami={**IDENTITY, "organization_id": "org_OLD"})
+ login_seams(["", "", PK, "", ""], whoami={**IDENTITY, "organization_id": "org_OLD"})
run(capsys, "auth", "login")
seams = login_seams(
- [PK, "", "", "beta-corp"],
+ ["", "", PK, "", "", "beta-corp"],
confirm=True,
whoami={
**IDENTITY,
@@ -2540,7 +2579,7 @@ def test_a_new_profile_offered_by_the_guard_keeps_the_host_the_key_was_checked_o
encoding="utf-8",
)
seams = login_seams(
- [PK, "", "", "beta"],
+ ["", "", PK, "", "", "beta"],
confirm=True,
whoami={**IDENTITY, "organization_id": "org_NEW"},
)
@@ -2569,7 +2608,7 @@ def test_a_profile_chosen_at_the_guard_is_replaced_not_merged_into(
encoding="utf-8",
)
seams = login_seams(
- [PK, "", "", "beta"],
+ ["", "", PK, "", "", "beta"],
confirm=True,
whoami={**IDENTITY, "organization_id": "org_NEW"},
)
@@ -2597,7 +2636,7 @@ def test_a_new_profile_name_that_belongs_to_a_third_organisation_is_confirmed(
encoding="utf-8",
)
seams = login_seams(
- [PK, "", "", "partner", "fresh"],
+ ["", "", PK, "", "", "partner", "fresh"],
confirm=[True, False],
whoami={**IDENTITY, "organization_id": "org_NEW"},
)
@@ -2613,17 +2652,19 @@ def test_a_new_profile_name_that_belongs_to_a_third_organisation_is_confirmed(
def test_login_overwrites_when_a_new_profile_is_declined(capsys, login_seams, tmp_path):
- login_seams([PK, "", ""], whoami={**IDENTITY, "organization_id": "org_OLD"})
+ login_seams(["", "", PK, "", ""], whoami={**IDENTITY, "organization_id": "org_OLD"})
run(capsys, "auth", "login")
seams = login_seams(
- [PK, "", ""], confirm=False, whoami={**IDENTITY, "organization_id": "org_NEW"}
+ ["", "", PK, "", ""],
+ confirm=False,
+ whoami={**IDENTITY, "organization_id": "org_NEW"},
)
code, out, _ = run(capsys, "auth", "login")
assert code == int(ExitCode.SUCCESS)
assert envelope(out)["data"]["profile"] == "cloud-us"
- assert len(seams["prompts"]) == 3
+ assert len(seams["prompts"]) == 5
assert 'org_id = "org_NEW"' in _written(tmp_path)
assert "org_OLD" not in _written(tmp_path)
@@ -2643,7 +2684,7 @@ def test_login_drops_the_keys_a_host_change_leaves_unchecked(
login that stores another one would send them somewhere they were never
accepted."""
(tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8")
- seams = login_seams([PK, "", ""], confirm=True)
+ seams = login_seams(["", "", PK, "", ""], confirm=True)
code, _, _ = run(capsys, "auth", "--base-url", "https://moved.example/", "login")
@@ -2663,7 +2704,7 @@ def test_login_aborts_rather_than_drop_keys_the_answer_declined(
"""Declining is a decision about the whole login: the keys are worth more
than the host change, so nothing is written at all."""
(tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8")
- login_seams([PK, "", ""], confirm=False)
+ login_seams(["", "", PK, "", ""], confirm=False)
code, _, _ = run(capsys, "auth", "--base-url", "https://moved.example/", "login")
@@ -2770,6 +2811,92 @@ def test_a_host_that_differs_beyond_spelling_still_strands_the_keys(
assert "deployment invoices" in err
+def test_a_host_typed_at_the_prompt_is_the_one_checked_and_stored(
+ capsys, login_seams, tmp_path
+):
+ """The on-premises path: Enter keeps the host the run resolved, anything
+ typed replaces it for the check and for the profile."""
+ seams = login_seams(["https://onprem.example/", "", PK, "", ""])
+
+ code, _, _ = run(capsys, "auth", "login")
+
+ assert code == int(ExitCode.SUCCESS)
+ assert seams["defaults"][:2] == [
+ "https://us-central.unstract.com",
+ "https://llmwhisperer-api.us-central.unstract.com/api/v2",
+ ]
+ assert seams["platform"].built_with["base_url"] == "https://onprem.example/"
+ assert 'base_url = "https://onprem.example/"' in _written(tmp_path)
+
+
+def test_the_host_prompt_offers_the_profile_s_own_host(capsys, login_seams, tmp_path):
+ (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8")
+ seams = login_seams(["", "", PK, "", ""])
+
+ code, _, _ = run(capsys, "auth", "login")
+
+ assert code == int(ExitCode.SUCCESS)
+ assert seams["defaults"][0] == "https://stored.example/"
+ assert seams["confirms"] == []
+
+
+def test_a_host_typed_at_the_prompt_is_stored_without_a_key_for_it(
+ capsys, login_seams, tmp_path
+):
+ """A self-hosted LLMWhisperer whose key comes later: the host typed now must
+ not depend on a key being typed with it."""
+ login_seams(["", "https://whisper.example/", PK, "", ""])
+
+ code, _, _ = run(capsys, "auth", "login")
+
+ assert code == int(ExitCode.SUCCESS)
+ text = _written(tmp_path)
+ assert "[profiles.cloud-us.llmwhisperer]" in text
+ assert 'base_url = "https://whisper.example/"' in text
+
+
+def test_the_host_prompt_refuses_anything_but_a_url(capsys, login_seams, tmp_path):
+ login_seams(["onprem.example", "", "", "", DK, ""])
+
+ code, _, err = run(capsys, "auth", "login")
+
+ assert code == int(ExitCode.SUCCESS)
+ assert "'onprem.example' is not an http(s) URL" in err
+ assert 'base_url = "onprem.example"' not in _written(tmp_path)
+
+
+def test_enter_at_the_host_prompt_keeps_a_profile_s_variable_reference(
+ capsys, login_seams, tmp_path, monkeypatch
+):
+ monkeypatch.setenv("MY_HOST", "https://stored.example/")
+ (tmp_path / "config.toml").write_text(
+ STRANDING_CONFIG.replace('"https://stored.example/"', '"env:MY_HOST"'),
+ encoding="utf-8",
+ )
+ seams = login_seams(["", "", PK, "", ""])
+
+ code, _, _ = run(capsys, "auth", "login")
+
+ assert code == int(ExitCode.SUCCESS)
+ assert seams["defaults"][0] == "https://stored.example/"
+ assert 'base_url = "env:MY_HOST"' in _written(tmp_path)
+
+
+def test_a_host_typed_at_the_prompt_goes_through_the_stranding_guard(
+ capsys, login_seams, tmp_path
+):
+ (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8")
+ seams = login_seams(["https://moved.example/", "", PK, "", ""], confirm=True)
+
+ code, _, _ = run(capsys, "auth", "login")
+
+ assert code == int(ExitCode.SUCCESS)
+ assert "deployment invoices" in seams["confirms"][0]
+ text = _written(tmp_path)
+ assert 'base_url = "https://moved.example/"' in text
+ assert "ENTRY-KEY-BBBB" not in text
+
+
def test_login_writes_the_profile_named_and_checks_against_its_own_host(
capsys, login_seams, tmp_path
):
diff --git a/tests/test_config.py b/tests/test_config.py
index 9dee8dd..ad0cb55 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -123,6 +123,19 @@ def test_require_names_every_way_to_supply_the_setting():
assert "--api-key" not in message
+def test_a_missing_setting_points_a_fresh_install_at_login(write_config):
+ """With no config file at all the hints name a file the reader has never
+ seen; `auth login` writes it. Once a file exists the hint would only
+ repeat what the file already shows."""
+ with pytest.raises(ConfigError, match="run `unstract auth login`"):
+ resolved().require(DOCSTUDIO, "api_key")
+
+ write_config('default_profile = "p"\n\n[profiles.p.docstudio]\norg_id = "x"\n')
+ with pytest.raises(ConfigError) as excinfo:
+ resolved().require(DOCSTUDIO, "api_key")
+ assert "auth login" not in str(excinfo.value)
+
+
def test_placeholder_is_not_a_value(write_config):
"""`config init` writes `org_id = ""`, and that must not satisfy `require`."""
write_config('default_profile = "p"\n\n[profiles.p.docstudio]\norg_id = ""\n')