From 1b0c7fa910702bd826ba35d9c99890f16ca868fd Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 17 Sep 2026 12:32:56 +0530 Subject: [PATCH 1/9] feat: point a fresh install at `auth login` when a setting is missing Before any config exists the "Missing required setting" hints name an environment variable and a TOML block the reader has never seen. The message now ends with the command that writes the file; once one exists the hint would only repeat what the file shows, so it goes away. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/config.py | 2 ++ tests/test_commands.py | 7 +++++++ tests/test_config.py | 13 +++++++++++++ 3 files changed, 22 insertions(+) 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_commands.py b/tests/test_commands.py index bda41ad..16ee451 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1576,6 +1576,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 ): 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') From 9ae5d16e64cb45dec116b5e810adba915eecead9 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 17 Sep 2026 12:35:47 +0530 Subject: [PATCH 2/9] feat: greet at the login wizard and ask for each product's host first The wizard opens with one line saying what it is for, then asks for each product's host before the keys, showing the one the run resolved so Enter keeps it and anything typed replaces it. The typed host reaches the same checked-host path a `--base-url` flag takes, so the guard that drops keys a host change leaves unchecked applies to it unchanged. Flag paths ask nothing and print nothing. The config and profile are now settled before the first prompt: the host offered is the profile's own, and a file that cannot be written is refused before anyone has typed a key into it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/commands/platform_cmd.py | 64 ++++++++++----- tests/test_commands.py | 97 ++++++++++++++++++----- 2 files changed, 118 insertions(+), 43 deletions(-) diff --git a/src/unstract_cli/commands/platform_cmd.py b/src/unstract_cli/commands/platform_cmd.py index 3a1f249..f104a31 100644 --- a/src/unstract_cli/commands/platform_cmd.py +++ b/src/unstract_cli/commands/platform_cmd.py @@ -172,6 +172,16 @@ def _keys_from_flags(given: dict[str, str | None]) -> dict[str, str | None]: return keys +def _hosts_from_prompts(resolved: ResolvedConfig) -> dict[str, str]: + """One visible prompt per product, Enter keeping the host the run resolved.""" + return { + f"{product}.base_url": _prompt( + f"{product} base URL", default=resolved.get(product, "base_url") + ).strip() + for product in (DOCSTUDIO, LLMWHISPERER) + } + + 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 +199,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 +212,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 +324,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 +349,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 +358,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 +369,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] = {} diff --git a/tests/test_commands.py b/tests/test_commands.py index 16ee451..c2e8aec 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2238,10 +2238,12 @@ 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")) + answer = state["answers"].pop(0) + return kwargs["default"] if answer == "" and kwargs.get("default") else answer 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 @@ -2281,15 +2283,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", @@ -2317,7 +2323,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") @@ -2327,7 +2333,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") @@ -2423,10 +2429,11 @@ def test_login_takes_the_platform_key_from_the_group_flag_too( ): 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) @@ -2449,7 +2456,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") @@ -2463,9 +2470,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") @@ -2513,10 +2520,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, @@ -2547,7 +2554,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"}, ) @@ -2576,7 +2583,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"}, ) @@ -2604,7 +2611,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"}, ) @@ -2620,17 +2627,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) @@ -2650,7 +2659,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") @@ -2670,7 +2679,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") @@ -2777,6 +2786,50 @@ 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_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 ): From 4e61a87acb63213a5eaab00acbc4f73d28206b45 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 17 Sep 2026 12:36:46 +0530 Subject: [PATCH 3/9] feat: say what comes after `config init`, and pin that login needs no file `config init` wrote the starter and stopped; the variables it names are in the file, not on the screen. It now says on stderr which to export, that `auth login` stores keys instead, and that `config doctor` shows the result. The login-first path is pinned: with no file and no directory, `auth login` creates both, the file owner-only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/commands/config_cmd.py | 17 +++++++++++++++++ tests/test_cli.py | 11 +++++++++++ tests/test_commands.py | 19 +++++++++++++++++++ 3 files changed, 47 insertions(+) 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/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 c2e8aec..33e3edc 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 @@ -2424,6 +2426,23 @@ 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 ): From 447e75c8aae7095e71dc22a4306a4c3682245b8b Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 17 Sep 2026 12:37:48 +0530 Subject: [PATCH 4/9] chore: name the project's pages in the package metadata Homepage, documentation, repository, issues and changelog, as PyPI shows them in the sidebar. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- pyproject.toml | 7 +++++++ 1 file changed, 7 insertions(+) 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", From 2471f1dd22e72207cf6d91c1bdcac7b2bf83c7d7 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 17 Sep 2026 12:38:29 +0530 Subject: [PATCH 5/9] feat: install from PyPI, and point the installed user at `auth login` The installer pulled `main` from GitHub because nothing was on PyPI. A release candidate is now, so it installs the package by name with pre-releases allowed; the flag goes with the first stable release. `UNSTRACT_CLI_SOURCE` still overrides the source. The closing line names `auth login`, the first thing the README has a new user run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- install.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index fdd8906..3893faa 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" +# Only release candidates are on PyPI so far; the prerelease flag goes with the first stable release. +uv tool install --force --prerelease allow "$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 From d423929a64b2199aeeb9d54c46a6095020aae48e Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 17 Sep 2026 12:41:51 +0530 Subject: [PATCH 6/9] docs: rewrite the README for someone installing from PyPI Install from PyPI (release candidates for now), where each key is minted, the login wizard as it now runs -- host prompts first, with the self-hosted note -- the variable names for CI, and how to verify. Depth goes to the docs site; the exit-code table stays. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- README.md | 248 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 127 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 1a1589c..ec97921 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,132 @@ # 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 text extraction and 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 --prerelease allow unstract-cli # or: pip install --pre unstract-cli +unstract --version +``` + +Only release candidates are on PyPI so far, hence the pre-release flag. + +## 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 | Identifies the organisation and lists what is in it (`auth whoami`, `deployment ls`); cannot run a deployment | +| **Deployment key** | The API deployment's own page in the Unstract UI; an organisation admin mints one covering every deployment in the organisation under **Settings → Global API Deployment Keys** | Runs deployments (`deployment run`, `deployment status`) | +| **LLMWhisperer key** | The LLMWhisperer console | Extracts text (`whisper …`) | + +You need only the keys for what you run. A platform key and a deployment key +together cover the whole Unstract side. + +## Set up + +```bash +unstract auth login +``` + +The wizard asks, in order: + +1. `docstudio base URL [https://us-central.unstract.com]:` — Enter keeps the + cloud host. Self-hosted? Type your own, e.g. `https://unstract.example.com`. +2. `llmwhisperer base URL [https://llmwhisperer-api.us-central.unstract.com/api/v2]:` + — the same for LLMWhisperer. +3. Platform key, deployment key, LLMWhisperer key — hidden input, Enter skips + one; at least one is needed. + +It checks the platform key and the LLMWhisperer key against their services +(a deployment key has nothing side-effect-free to call, so it is stored as +given), resolves your organisation, and writes `~/.unstract/config.toml` +owner-only. Run it again to rotate a key; a login that stores a different host +asks before dropping keys that were checked against the old one. + +Then verify: + +```bash +unstract auth whoami # which organisation the platform key belongs to +unstract config doctor --probe # where each setting resolved from, keys checked +``` + +## Without a terminal: CI, containers, agents + +No prompts and no file — the environment is the profile: + +```bash +export UNSTRACT_PLATFORM_KEY=... # optional: 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=https://unstract.example.com # self-hosted only +export LLMWHISPERER_BASE_URL=https://whisperer.example.com/api/v2 # self-hosted only + +unstract -o json auth whoami +``` + +Or store them once, non-interactively — keys as flags, any one of them `-` to +read from stdin, and `--base-url` for a self-hosted host: ```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 +printf '%s' "$UNSTRACT_PLATFORM_KEY" | unstract auth login \ + --platform-key - \ + --deployment-key "$UNSTRACT_DEPLOYMENT_KEY" \ + --llmwhisperer-key "$LLMWHISPERER_API_KEY" ``` -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. +`unstract config init` writes a starter file that references those variables +(`api_key = "env:UNSTRACT_DEPLOYMENT_KEY"`) instead of holding secrets, so it is +safe to commit. Every setting resolves **flag > environment > profile > +built-in default**. + +## Use it + +```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 -Or run it without installing: `uvx --from git+https://github.com/Zipstack/unstract-cli unstract --discover groups`. +# Long job: submit, then check later +unstract docstudio deployment run invoice-parser invoice.pdf --no-wait +unstract docstudio deployment status invoice-parser +``` -## Output +`--help` on any command lists its options; `unstract --discover full` prints +the whole command tree, every flag and the exit-code table as JSON. -`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. +## 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. +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. -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. - -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 +143,45 @@ 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. +`~/.unstract/config.toml`, or `$UNSTRACT_CONFIG`, or `--config`, or a +project-local `.unstract.toml` found by upward search (which may select a +profile and an `org_id` but may not supply a key or a host — name the file +explicitly and it is honoured in full). ```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" +api_key = "env:UNSTRACT_DEPLOYMENT_KEY" -# Only for a deployment whose key differs from the one above. +[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" ``` -`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. +`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. + +`clone` moves one organisation's resources into another, holding two admin +platform keys as `UNSTRACT_SRC_PLATFORM_KEY` / `UNSTRACT_TGT_PLATFORM_KEY`. It +is an operator command, not part of the document-processing path. ## Development ```bash -uv venv && uv pip install -e '.[dev]' +uv sync --extra dev uv run pytest # offline; no network, no credentials uv run ruff check . ``` From 5e983566fe08afad451f9f12fac86de9512edb29 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 17 Sep 2026 13:08:41 +0530 Subject: [PATCH 7/9] docs: link the products, and trim the README to what a user needs Install from PyPI as for any release; product names link to their docs; the wizard in one line; configuration ahead of the environment-variable form, which is now a subsection of it; clone as a note under usage. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- README.md | 133 ++++++++++++++++++++---------------------------------- 1 file changed, 50 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index ec97921..a60782d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # unstract-cli -`unstract` runs LLMWhisperer text extraction and Unstract API deployments from -the terminal. Pass `-o json` and every command prints one JSON envelope, so a +`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: @@ -17,82 +18,80 @@ installs the CLI with it; `uv` brings its own Python. With `uv` or `pip` already there: ```bash -uv tool install --prerelease allow unstract-cli # or: pip install --pre unstract-cli +uv tool install unstract-cli # or: pip install unstract-cli unstract --version ``` -Only release candidates are on PyPI so far, hence the pre-release flag. - ## 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 | Identifies the organisation and lists what is in it (`auth whoami`, `deployment ls`); cannot run a deployment | -| **Deployment key** | The API deployment's own page in the Unstract UI; an organisation admin mints one covering every deployment in the organisation under **Settings → Global API Deployment Keys** | Runs deployments (`deployment run`, `deployment status`) | +| **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 …`) | -You need only the keys for what you run. A platform key and a deployment key -together cover the whole Unstract side. - ## Set up ```bash unstract auth login ``` -The wizard asks, in order: - -1. `docstudio base URL [https://us-central.unstract.com]:` — Enter keeps the - cloud host. Self-hosted? Type your own, e.g. `https://unstract.example.com`. -2. `llmwhisperer base URL [https://llmwhisperer-api.us-central.unstract.com/api/v2]:` - — the same for LLMWhisperer. -3. Platform key, deployment key, LLMWhisperer key — hidden input, Enter skips - one; at least one is needed. - -It checks the platform key and the LLMWhisperer key against their services -(a deployment key has nothing side-effect-free to call, so it is stored as -given), resolves your organisation, and writes `~/.unstract/config.toml` -owner-only. Run it again to rotate a key; a login that stores a different host -asks before dropping keys that were checked against the old one. - -Then verify: +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 unstract auth whoami # which organisation the platform key belongs to unstract config doctor --probe # where each setting resolved from, keys checked ``` -## Without a terminal: CI, containers, agents +## Configuration -No prompts and no file — the environment is the profile: +`~/.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. -```bash -export UNSTRACT_PLATFORM_KEY=... # optional: 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=https://unstract.example.com # self-hosted only -export LLMWHISPERER_BASE_URL=https://whisperer.example.com/api/v2 # self-hosted only +```toml +default_profile = "cloud-us" -unstract -o json auth whoami +[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" ``` -Or store them once, non-interactively — keys as flags, any one of them `-` to -read from stdin, and `--base-url` for a self-hosted host: +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 -printf '%s' "$UNSTRACT_PLATFORM_KEY" | unstract auth login \ - --platform-key - \ - --deployment-key "$UNSTRACT_DEPLOYMENT_KEY" \ - --llmwhisperer-key "$LLMWHISPERER_API_KEY" +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 config init` writes a starter file that references those variables -(`api_key = "env:UNSTRACT_DEPLOYMENT_KEY"`) instead of holding secrets, so it is -safe to commit. Every setting resolves **flag > environment > profile > -built-in default**. +`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. -## Use it +## Usage ```bash # Extract text from a document (path or URL); waits for the result @@ -112,6 +111,9 @@ 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, @@ -143,45 +145,10 @@ Failures exit non-zero with a stable code: | 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 | -## Configuration - -`~/.unstract/config.toml`, or `$UNSTRACT_CONFIG`, or `--config`, or a -project-local `.unstract.toml` found by upward search (which may select a -profile and an `org_id` but may not supply a key or a host — name the file -explicitly and it is honoured in full). - -```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" -``` - -`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. - -`clone` moves one organisation's resources into another, holding two admin -platform keys as `UNSTRACT_SRC_PLATFORM_KEY` / `UNSTRACT_TGT_PLATFORM_KEY`. It -is an operator command, not part of the document-processing path. - ## Development ```bash uv sync --extra dev -uv run pytest # offline; no network, no credentials +uv run pytest uv run ruff check . ``` From a889d2645b77d43d36c724d0af5e733e45ee12ca Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 17 Sep 2026 13:08:41 +0530 Subject: [PATCH 8/9] fix: take a pre-release only while no stable release exists `--prerelease allow` would keep picking a newer release candidate over the stable release once both are on PyPI, and needed removing at the first stable release. `if-necessary` installs the rc now and the stable release as soon as there is one, with nothing to remember later. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 3893faa..d5c24d9 100755 --- a/install.sh +++ b/install.sh @@ -21,8 +21,8 @@ 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. -# Only release candidates are on PyPI so far; the prerelease flag goes with the first stable release. -uv tool install --force --prerelease allow "$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 From 8ef1b24d5d86a14b72b78a01135166cde3aa6fd4 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 17 Sep 2026 13:45:35 +0530 Subject: [PATCH 9/9] fix: store a host typed at the login prompt even without a key for it The host was only written alongside a key for the same product, so typing a self-hosted LLMWhisperer URL and skipping its key kept the old host -- after the stranding guard had already acted on the new one. Enter now means no change, so a profile that references a variable for the host keeps the reference, and anything that is not an http(s) URL is asked again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/commands/platform_cmd.py | 25 ++++++++--- tests/test_commands.py | 52 ++++++++++++++++++++++- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/unstract_cli/commands/platform_cmd.py b/src/unstract_cli/commands/platform_cmd.py index f104a31..776e348 100644 --- a/src/unstract_cli/commands/platform_cmd.py +++ b/src/unstract_cli/commands/platform_cmd.py @@ -172,14 +172,22 @@ 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, Enter keeping the host the run resolved.""" - return { - f"{product}.base_url": _prompt( - f"{product} base URL", default=resolved.get(product, "base_url") - ).strip() - for product in (DOCSTUDIO, LLMWHISPERER) - } + """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]: @@ -486,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/tests/test_commands.py b/tests/test_commands.py index 33e3edc..f29d432 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2241,8 +2241,14 @@ def login_seams(monkeypatch, platform_client, tmp_path): def prompt(text, **kwargs): state["prompts"].append(text) state["defaults"].append(kwargs.get("default")) - answer = state["answers"].pop(0) - return kwargs["default"] if answer == "" and kwargs.get("default") else answer + 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"], state["defaults"] = list(answers), [], [] @@ -2834,6 +2840,48 @@ def test_the_host_prompt_offers_the_profile_s_own_host(capsys, login_seams, tmp_ 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 ):