typesafe-unofficial-cli 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. typesafe_unofficial_cli/__init__.py +3 -0
  2. typesafe_unofficial_cli/__main__.py +3 -0
  3. typesafe_unofficial_cli/_version.py +3 -0
  4. typesafe_unofficial_cli/auth/__init__.py +0 -0
  5. typesafe_unofficial_cli/auth/credentials.py +75 -0
  6. typesafe_unofficial_cli/auth/env_store.py +28 -0
  7. typesafe_unofficial_cli/auth/file_store.py +51 -0
  8. typesafe_unofficial_cli/auth/keyring_store.py +64 -0
  9. typesafe_unofficial_cli/auth/resolver.py +35 -0
  10. typesafe_unofficial_cli/commands/__init__.py +0 -0
  11. typesafe_unofficial_cli/commands/ask.py +149 -0
  12. typesafe_unofficial_cli/commands/auth.py +193 -0
  13. typesafe_unofficial_cli/commands/config.py +134 -0
  14. typesafe_unofficial_cli/commands/models.py +49 -0
  15. typesafe_unofficial_cli/config/__init__.py +0 -0
  16. typesafe_unofficial_cli/config/paths.py +25 -0
  17. typesafe_unofficial_cli/config/providers.py +37 -0
  18. typesafe_unofficial_cli/config/settings.py +112 -0
  19. typesafe_unofficial_cli/config/store.py +56 -0
  20. typesafe_unofficial_cli/main.py +114 -0
  21. typesafe_unofficial_cli/output/__init__.py +0 -0
  22. typesafe_unofficial_cli/output/columns.py +35 -0
  23. typesafe_unofficial_cli/output/formats.py +82 -0
  24. typesafe_unofficial_cli/output/registry.py +29 -0
  25. typesafe_unofficial_cli/output/renderer.py +70 -0
  26. typesafe_unofficial_cli/runtime/__init__.py +0 -0
  27. typesafe_unofficial_cli/runtime/client_factory.py +76 -0
  28. typesafe_unofficial_cli/runtime/context.py +100 -0
  29. typesafe_unofficial_cli/runtime/errors.py +85 -0
  30. typesafe_unofficial_cli/runtime/exit_codes.py +15 -0
  31. typesafe_unofficial_cli/runtime/params.py +42 -0
  32. typesafe_unofficial_cli/runtime/request_options.py +57 -0
  33. typesafe_unofficial_cli/services/__init__.py +0 -0
  34. typesafe_unofficial_cli/services/answers.py +69 -0
  35. typesafe_unofficial_cli/services/batch.py +57 -0
  36. typesafe_unofficial_cli/services/inputs.py +103 -0
  37. typesafe_unofficial_cli/services/validation.py +22 -0
  38. typesafe_unofficial_cli-1.0.1.dist-info/METADATA +118 -0
  39. typesafe_unofficial_cli-1.0.1.dist-info/RECORD +42 -0
  40. typesafe_unofficial_cli-1.0.1.dist-info/WHEEL +4 -0
  41. typesafe_unofficial_cli-1.0.1.dist-info/entry_points.txt +2 -0
  42. typesafe_unofficial_cli-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ from typesafe_unofficial_cli._version import __version__
2
+
3
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from typesafe_unofficial_cli.main import run
2
+
3
+ run()
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("typesafe-unofficial-cli")
File without changes
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from enum import StrEnum
6
+ from typing import TYPE_CHECKING
7
+ from typing import Final
8
+ from typing import Literal
9
+ from typing import Protocol
10
+
11
+ if TYPE_CHECKING:
12
+ from typesafe_unofficial_cli.config.providers import Provider
13
+
14
+ _VISIBLE_SUFFIX: Final = 4
15
+
16
+
17
+ class CredentialSource(StrEnum):
18
+ ENVIRONMENT = "environment"
19
+ KEYRING = "keyring"
20
+ FILE = "file"
21
+
22
+
23
+ # Both providers authenticate with a bearer API key. The tagged encoding leaves room for another
24
+ # credential kind without migrating what is already stored.
25
+ @dataclass(frozen=True, slots=True)
26
+ class ApiKeyCredential:
27
+ api_key: str
28
+ kind: Literal["api_key"] = "api_key"
29
+
30
+ def masked(self) -> str:
31
+ if len(self.api_key) <= _VISIBLE_SUFFIX:
32
+ return "*" * len(self.api_key)
33
+ return "*" * 8 + self.api_key[-_VISIBLE_SUFFIX:]
34
+
35
+
36
+ type Credential = ApiKeyCredential # pylint: disable=gajaguar-module-const-naming
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ResolvedCredential:
41
+ credential: Credential
42
+ source: CredentialSource
43
+
44
+
45
+ def encode(credential: Credential) -> str:
46
+ return json.dumps({"kind": credential.kind, "api_key": credential.api_key})
47
+
48
+
49
+ def decode(raw: str) -> Credential | None:
50
+ try:
51
+ payload: object = json.loads(raw)
52
+ except ValueError:
53
+ return None
54
+ if not isinstance(payload, dict) or payload.get("kind") != "api_key":
55
+ return None
56
+ api_key = payload.get("api_key")
57
+ if not isinstance(api_key, str) or not api_key:
58
+ return None
59
+ return ApiKeyCredential(api_key=api_key)
60
+
61
+
62
+ # `provider` only matters to the environment store, which reads a different variable per provider.
63
+ class CredentialStore(Protocol):
64
+ @property
65
+ def source(self) -> CredentialSource: ...
66
+
67
+ def get(self, profile: str, provider: Provider) -> Credential | None: ...
68
+
69
+
70
+ class WritableCredentialStore(CredentialStore, Protocol):
71
+ def available(self) -> bool: ...
72
+
73
+ def set(self, profile: str, credential: Credential) -> None: ...
74
+
75
+ def delete(self, profile: str) -> bool: ...
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from typesafe_unofficial_cli.auth.credentials import ApiKeyCredential
6
+ from typesafe_unofficial_cli.auth.credentials import CredentialSource
7
+ from typesafe_unofficial_cli.config.providers import spec_for
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Mapping
11
+
12
+ from typesafe_unofficial_cli.config.providers import Provider
13
+
14
+
15
+ # The environment key is profile-agnostic: it overrides whatever profile is selected, for CI. It is
16
+ # looked up by the profile's provider, so each host only ever receives its own key.
17
+ class EnvCredentialStore:
18
+ def __init__(self, environ: Mapping[str, str]) -> None:
19
+ self._environ = environ
20
+
21
+ @property
22
+ def source(self) -> CredentialSource:
23
+ return CredentialSource.ENVIRONMENT
24
+
25
+ def get(self, profile: str, provider: Provider) -> ApiKeyCredential | None:
26
+ del profile
27
+ api_key = self._environ.get(spec_for(provider).api_key_env_var, "").strip()
28
+ return ApiKeyCredential(api_key=api_key) if api_key else None
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from typesafe_unofficial_cli.auth.credentials import CredentialSource
6
+ from typesafe_unofficial_cli.auth.credentials import decode
7
+ from typesafe_unofficial_cli.auth.credentials import encode
8
+ from typesafe_unofficial_cli.config.store import read_toml
9
+ from typesafe_unofficial_cli.config.store import write_private_toml
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ from typesafe_unofficial_cli.auth.credentials import Credential
15
+ from typesafe_unofficial_cli.config.providers import Provider
16
+
17
+
18
+ # Plaintext fallback for hosts without a keyring backend; only written on explicit opt-in.
19
+ class FileCredentialStore:
20
+ def __init__(self, path: Path) -> None:
21
+ self.path = path
22
+
23
+ @property
24
+ def source(self) -> CredentialSource:
25
+ return CredentialSource.FILE
26
+
27
+ @staticmethod
28
+ def available() -> bool:
29
+ return True
30
+
31
+ def get(self, profile: str, provider: Provider) -> Credential | None:
32
+ del provider
33
+ raw = self._entries().get(profile)
34
+ return decode(raw) if isinstance(raw, str) else None
35
+
36
+ def set(self, profile: str, credential: Credential) -> None:
37
+ entries = self._entries()
38
+ entries[profile] = encode(credential)
39
+ write_private_toml(self.path, {"profiles": entries})
40
+
41
+ def delete(self, profile: str) -> bool:
42
+ entries = self._entries()
43
+ if profile not in entries:
44
+ return False
45
+ del entries[profile]
46
+ write_private_toml(self.path, {"profiles": entries})
47
+ return True
48
+
49
+ def _entries(self) -> dict[str, object]:
50
+ profiles = read_toml(self.path).get("profiles")
51
+ return dict(profiles) if isinstance(profiles, dict) else {}
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from typing import Final
5
+
6
+ import keyring
7
+ from keyring.backends.fail import Keyring as FailKeyring
8
+ from keyring.errors import KeyringError
9
+ from keyring.errors import PasswordDeleteError
10
+
11
+ from typesafe_unofficial_cli.auth.credentials import CredentialSource
12
+ from typesafe_unofficial_cli.auth.credentials import decode
13
+ from typesafe_unofficial_cli.auth.credentials import encode
14
+ from typesafe_unofficial_cli.runtime.errors import CliError
15
+ from typesafe_unofficial_cli.runtime.exit_codes import ExitCode
16
+
17
+ if TYPE_CHECKING:
18
+ from typesafe_unofficial_cli.auth.credentials import Credential
19
+ from typesafe_unofficial_cli.config.providers import Provider
20
+
21
+ SERVICE_NAME: Final = "typesafe-cli"
22
+
23
+
24
+ class KeyringCredentialStore:
25
+ def __init__(self, service: str = SERVICE_NAME) -> None:
26
+ self._service = service
27
+
28
+ @property
29
+ def source(self) -> CredentialSource:
30
+ return CredentialSource.KEYRING
31
+
32
+ @staticmethod
33
+ def available() -> bool:
34
+ return not isinstance(keyring.get_keyring(), FailKeyring)
35
+
36
+ def get(self, profile: str, provider: Provider) -> Credential | None:
37
+ del provider
38
+ if not self.available():
39
+ return None
40
+ try:
41
+ raw = keyring.get_password(self._service, profile)
42
+ except KeyringError:
43
+ return None
44
+ return decode(raw) if raw else None
45
+
46
+ def set(self, profile: str, credential: Credential) -> None:
47
+ try:
48
+ keyring.set_password(self._service, profile, encode(credential))
49
+ except KeyringError as error:
50
+ message = f"Could not write to the system keyring: {error}"
51
+ raise CliError(
52
+ message,
53
+ exit_code=ExitCode.CONFIGURATION,
54
+ hint="Retry with --insecure-storage to use a 0600 file instead.",
55
+ ) from error
56
+
57
+ def delete(self, profile: str) -> bool:
58
+ if not self.available():
59
+ return False
60
+ try:
61
+ keyring.delete_password(self._service, profile)
62
+ except PasswordDeleteError:
63
+ return False
64
+ return True
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ from typesafe_unofficial_cli.auth.credentials import ResolvedCredential
7
+
8
+ if TYPE_CHECKING:
9
+ from typesafe_unofficial_cli.auth.credentials import CredentialStore
10
+ from typesafe_unofficial_cli.auth.credentials import WritableCredentialStore
11
+ from typesafe_unofficial_cli.auth.env_store import EnvCredentialStore
12
+ from typesafe_unofficial_cli.auth.file_store import FileCredentialStore
13
+ from typesafe_unofficial_cli.auth.keyring_store import KeyringCredentialStore
14
+ from typesafe_unofficial_cli.config.providers import Provider
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class CredentialStores:
19
+ environment: EnvCredentialStore
20
+ keyring: KeyringCredentialStore
21
+ file: FileCredentialStore
22
+
23
+ # Precedence order: the first store holding a credential for the profile wins.
24
+ def chain(self) -> tuple[CredentialStore, ...]:
25
+ return (self.environment, self.keyring, self.file)
26
+
27
+ def writable(self) -> tuple[WritableCredentialStore, ...]:
28
+ return (self.keyring, self.file)
29
+
30
+ def resolve(self, profile: str, provider: Provider) -> ResolvedCredential | None:
31
+ for store in self.chain():
32
+ credential = store.get(profile, provider)
33
+ if credential is not None:
34
+ return ResolvedCredential(credential=credential, source=store.source)
35
+ return None
File without changes
@@ -0,0 +1,149 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import TYPE_CHECKING
4
+ from typing import Annotated
5
+ from typing import Final
6
+ from typing import cast
7
+
8
+ import typer
9
+
10
+ from typesafe_unofficial_cli.output.columns import ANSWERS
11
+ from typesafe_unofficial_cli.output.columns import ANSWERS_BATCH
12
+ from typesafe_unofficial_cli.output.renderer import Dataset
13
+ from typesafe_unofficial_cli.runtime.context import get_app_context
14
+ from typesafe_unofficial_cli.runtime.errors import CliError
15
+ from typesafe_unofficial_cli.runtime.errors import exit_code_for
16
+ from typesafe_unofficial_cli.runtime.errors import handle_errors
17
+ from typesafe_unofficial_cli.runtime.errors import report
18
+ from typesafe_unofficial_cli.runtime.exit_codes import ExitCode
19
+ from typesafe_unofficial_cli.runtime.params import options_from
20
+ from typesafe_unofficial_cli.runtime.request_options import RequestOptions
21
+ from typesafe_unofficial_cli.services.answers import answer_records
22
+ from typesafe_unofficial_cli.services.answers import batch_usage_line
23
+ from typesafe_unofficial_cli.services.answers import usage_line
24
+ from typesafe_unofficial_cli.services.batch import BatchRequest
25
+ from typesafe_unofficial_cli.services.batch import run_batch
26
+ from typesafe_unofficial_cli.services.inputs import STDIN_MARKER
27
+ from typesafe_unofficial_cli.services.inputs import StateFormat
28
+ from typesafe_unofficial_cli.services.inputs import read_extra_body
29
+ from typesafe_unofficial_cli.services.inputs import read_questions
30
+ from typesafe_unofficial_cli.services.inputs import read_state
31
+ from typesafe_unofficial_cli.services.inputs import read_states
32
+
33
+ if TYPE_CHECKING:
34
+ from typesafe_sdk import JSONValue
35
+ from typesafe_sdk import QuestionModel
36
+ from typesafe_sdk import TypeSafeError
37
+
38
+ from typesafe_unofficial_cli.runtime.context import AppContext
39
+ from typesafe_unofficial_cli.runtime.request_options import RequestOverrides
40
+
41
+ APP: Final = typer.Typer()
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class _AskOptions(RequestOptions):
46
+ state: Annotated[str | None, typer.Option("--state", help="The text to evaluate.")] = None
47
+ state_file: Annotated[
48
+ Path | None,
49
+ typer.Option("--state-file", help="Read the state from a file; `-` reads standard input."),
50
+ ] = None
51
+ state_format: Annotated[
52
+ StateFormat,
53
+ typer.Option(
54
+ "--state-format", case_sensitive=False, help="Parse the state as plain text or as a JSON object/array."
55
+ ),
56
+ ] = StateFormat.TEXT
57
+ questions_file: Annotated[
58
+ Path | None,
59
+ typer.Option(
60
+ "--questions-file",
61
+ help='JSON object of named questions, each {"type": "noul|choice|score", ...}; `-` reads standard input.',
62
+ ),
63
+ ] = None
64
+ states_file: Annotated[
65
+ Path | None,
66
+ typer.Option(
67
+ "--states-file",
68
+ help="Batch mode: one JSON state (string, object or array) per line; `-` reads standard input.",
69
+ ),
70
+ ] = None
71
+ concurrency: Annotated[
72
+ int, typer.Option("--concurrency", min=1, help="Batch mode: requests in flight at once.")
73
+ ] = 4
74
+ extra_body: Annotated[
75
+ Path | None,
76
+ typer.Option("--extra-body", help="JSON object of extra top-level request fields; `-` reads standard input."),
77
+ ] = None
78
+ model: Annotated[
79
+ str | None, typer.Option("--model", help="Model name; defaults to the profile's, else jev-latest.")
80
+ ] = None
81
+
82
+
83
+ @APP.command(help="Answer named questions (noul, choice, score) about a state with a System One model.")
84
+ @handle_errors
85
+ @options_from(_AskOptions)
86
+ def ask(ctx: typer.Context, options: _AskOptions) -> None:
87
+ if options.questions_file is None:
88
+ message = "--questions-file is required."
89
+ raise CliError(message, exit_code=ExitCode.USAGE)
90
+ stdin_sources = [
91
+ flag
92
+ for flag, source in (
93
+ ("--state-file", options.state_file),
94
+ ("--states-file", options.states_file),
95
+ ("--questions-file", options.questions_file),
96
+ ("--extra-body", options.extra_body),
97
+ )
98
+ if str(source) == STDIN_MARKER
99
+ ]
100
+ if len(stdin_sources) > 1:
101
+ message = f"Only one of {', '.join(stdin_sources)} can read standard input."
102
+ raise CliError(message, exit_code=ExitCode.USAGE)
103
+ if options.states_file is not None and (options.state is not None or options.state_file is not None):
104
+ message = "--states-file cannot be combined with --state or --state-file."
105
+ raise CliError(message, exit_code=ExitCode.USAGE)
106
+ app_context = get_app_context(ctx)
107
+ overrides = options.overrides()
108
+ questions = read_questions(options.questions_file)
109
+ extra_body = None if options.extra_body is None else read_extra_body(options.extra_body)
110
+ if options.states_file is not None:
111
+ _ask_batch(app_context, options, overrides, questions, extra_body)
112
+ return
113
+ content = read_state(text=options.state, file=options.state_file, state_format=options.state_format)
114
+ response = app_context.client(overrides).system_one(content, questions, model=options.model, extra_body=extra_body)
115
+ app_context.notify(usage_line(response))
116
+ app_context.render(Dataset(records=answer_records(response), columns=ANSWERS, id_key="question"))
117
+
118
+
119
+ # Answers are rendered first, then each failed state is reported and the first failure sets the exit code.
120
+ def _ask_batch(
121
+ app_context: AppContext,
122
+ options: _AskOptions,
123
+ overrides: RequestOverrides,
124
+ questions: dict[str, QuestionModel],
125
+ extra_body: dict[str, JSONValue | None] | None,
126
+ ) -> None:
127
+ request = BatchRequest(
128
+ states=read_states(cast("Path", options.states_file)),
129
+ questions=questions,
130
+ model=options.model,
131
+ extra_body=extra_body,
132
+ concurrency=options.concurrency,
133
+ )
134
+ items = run_batch(app_context.async_client(overrides), request)
135
+ answered = [item.response for item in items if item.response is not None]
136
+ records = [
137
+ record
138
+ for item in items
139
+ if item.response is not None
140
+ for record in answer_records(item.response, index=item.index)
141
+ ]
142
+ app_context.notify(batch_usage_line(answered))
143
+ app_context.render(Dataset(records=records, columns=ANSWERS_BATCH, id_key="question"))
144
+ failures = [item for item in items if item.error is not None]
145
+ for item in failures:
146
+ report(f"state {item.index}: {item.error}")
147
+ if failures:
148
+ message = f"{len(failures)} of {len(items)} states failed."
149
+ raise CliError(message, exit_code=exit_code_for(cast("TypeSafeError", failures[0].error)))
@@ -0,0 +1,193 @@
1
+ import sys
2
+ from dataclasses import dataclass
3
+ from typing import TYPE_CHECKING
4
+ from typing import Annotated
5
+ from typing import Final
6
+
7
+ import typer
8
+ from pydantic import ValidationError
9
+
10
+ from typesafe_unofficial_cli.auth.credentials import ApiKeyCredential
11
+ from typesafe_unofficial_cli.config.providers import Provider
12
+ from typesafe_unofficial_cli.config.providers import spec_for
13
+ from typesafe_unofficial_cli.config.settings import Profile
14
+ from typesafe_unofficial_cli.output.columns import AUTH_STATUS
15
+ from typesafe_unofficial_cli.output.renderer import single
16
+ from typesafe_unofficial_cli.runtime.client_factory import ClientRequest
17
+ from typesafe_unofficial_cli.runtime.context import get_app_context
18
+ from typesafe_unofficial_cli.runtime.errors import CliError
19
+ from typesafe_unofficial_cli.runtime.errors import handle_errors
20
+ from typesafe_unofficial_cli.runtime.exit_codes import ExitCode
21
+ from typesafe_unofficial_cli.runtime.params import options_from
22
+ from typesafe_unofficial_cli.services.validation import verify_key
23
+
24
+ if TYPE_CHECKING:
25
+ from typesafe_unofficial_cli.auth.credentials import WritableCredentialStore
26
+ from typesafe_unofficial_cli.auth.resolver import CredentialStores
27
+
28
+ APP: Final = typer.Typer(help="Log in, inspect, and remove API credentials.", no_args_is_help=True)
29
+
30
+
31
+ def _read_api_key(provider: Provider, *, with_token: bool) -> str:
32
+ # No --api-key flag on purpose: a key passed as an argument ends up in shell history.
33
+ raw = (
34
+ sys.stdin.read()
35
+ if with_token
36
+ else typer.prompt(f"{spec_for(provider).display_name} API key", hide_input=True, err=True)
37
+ )
38
+ api_key = str(raw).strip()
39
+ if not api_key:
40
+ message = "The API key is empty."
41
+ raise CliError(message, exit_code=ExitCode.USAGE)
42
+ return api_key
43
+
44
+
45
+ def _select_store(stores: CredentialStores, *, insecure_storage: bool) -> WritableCredentialStore:
46
+ if insecure_storage:
47
+ return stores.file
48
+ if stores.keyring.available():
49
+ return stores.keyring
50
+ message = "No system keyring backend is available."
51
+ raise CliError(
52
+ message,
53
+ exit_code=ExitCode.CONFIGURATION,
54
+ hint="Retry with --insecure-storage, or set the provider's API key variable in the environment.",
55
+ )
56
+
57
+
58
+ def _status_record(profile_name: str, profile: Profile, source: str, masked_key: str) -> dict[str, object]:
59
+ return {
60
+ "profile": profile_name,
61
+ "provider": profile.provider,
62
+ "base_url": profile.effective_base_url(),
63
+ "model": profile.model,
64
+ "source": source,
65
+ "api_key": masked_key,
66
+ }
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class _LoginOptions:
71
+ provider: Annotated[
72
+ Provider | None,
73
+ typer.Option(
74
+ "--provider",
75
+ case_sensitive=False,
76
+ help="Where the key is valid; defaults to the profile's, else typesafe.",
77
+ ),
78
+ ] = None
79
+ base_url: Annotated[
80
+ str | None,
81
+ typer.Option("--base-url", help="Override the provider's API root, e.g. for a proxy."),
82
+ ] = None
83
+ with_token: Annotated[bool, typer.Option("--with-token", help="Read the API key from standard input.")] = False
84
+ insecure_storage: Annotated[
85
+ bool,
86
+ typer.Option("--insecure-storage", help="Store the key in a 0600 plaintext file instead of the keyring."),
87
+ ] = False
88
+ skip_validation: Annotated[
89
+ bool,
90
+ typer.Option("--skip-validation", help="Store the key without calling the provider."),
91
+ ] = False
92
+
93
+
94
+ @APP.command(
95
+ help=(
96
+ "Validate an API key and store it for the selected profile. "
97
+ "A TypeSafe key is checked for free; an OpenRouter key needs one minimal billed request "
98
+ "(skip it with --skip-validation). Environment variables for automation: "
99
+ "TYPESAFE_API_KEY (provider typesafe) and OPENROUTER_API_KEY (provider openrouter)."
100
+ ),
101
+ )
102
+ @handle_errors
103
+ @options_from(_LoginOptions)
104
+ def login(ctx: typer.Context, options: _LoginOptions) -> None:
105
+ app_context = get_app_context(ctx)
106
+ stores = app_context.services.credentials
107
+ profile_name = app_context.options.profile_name
108
+ current = app_context.options.profile
109
+ resolved_provider = options.provider or current.provider
110
+ # A different provider invalidates the old override and default model, which belong to the old host.
111
+ keeps_settings = resolved_provider is current.provider
112
+ try:
113
+ profile = Profile(
114
+ provider=resolved_provider,
115
+ base_url=options.base_url or (current.base_url if keeps_settings else None),
116
+ model=current.model if keeps_settings else None,
117
+ )
118
+ except ValidationError as error:
119
+ message = error.errors()[0]["msg"].removeprefix("Value error, ")
120
+ raise CliError(message, exit_code=ExitCode.USAGE) from error
121
+ credential = ApiKeyCredential(api_key=_read_api_key(resolved_provider, with_token=options.with_token))
122
+ if not options.skip_validation:
123
+ request = ClientRequest(
124
+ credential=credential,
125
+ base_url=profile.effective_base_url(),
126
+ model=profile.model,
127
+ verbose=app_context.options.verbose,
128
+ )
129
+ with app_context.services.clients(request) as client:
130
+ verify_key(client, resolved_provider)
131
+ store = _select_store(stores, insecure_storage=options.insecure_storage)
132
+ store.set(profile_name, credential)
133
+ for other in stores.writable():
134
+ if other is not store:
135
+ other.delete(profile_name)
136
+ settings = app_context.services.settings.load()
137
+ if not settings.profiles:
138
+ settings = settings.model_copy(update={"default_profile": profile_name})
139
+ app_context.services.settings.save(settings.with_profile(profile_name, profile))
140
+ app_context.notify(
141
+ f"Logged in to profile '{profile_name}' ({spec_for(resolved_provider).display_name}, {store.source})."
142
+ )
143
+ app_context.render(single(_status_record(profile_name, profile, store.source, credential.masked()), AUTH_STATUS))
144
+
145
+
146
+ @APP.command(help="Show the active credential; --check also validates it with the provider.")
147
+ @handle_errors
148
+ def status(
149
+ ctx: typer.Context,
150
+ *,
151
+ check: Annotated[
152
+ bool, typer.Option("--check", help="Validate the key with the provider (OpenRouter bills one request).")
153
+ ] = False,
154
+ ) -> None:
155
+ app_context = get_app_context(ctx)
156
+ resolved = app_context.credential()
157
+ if check:
158
+ verify_key(app_context.client(), app_context.options.profile.provider)
159
+ record = _status_record(
160
+ app_context.options.profile_name,
161
+ app_context.options.profile,
162
+ resolved.source,
163
+ resolved.credential.masked(),
164
+ )
165
+ app_context.render(single(record, AUTH_STATUS))
166
+
167
+
168
+ @APP.command(help="Remove the stored credential and profile.")
169
+ @handle_errors
170
+ def logout(ctx: typer.Context) -> None:
171
+ app_context = get_app_context(ctx)
172
+ services = app_context.services
173
+ profile_name = app_context.options.profile_name
174
+ removed = [store.source for store in services.credentials.writable() if store.delete(profile_name)]
175
+ settings = services.settings.load()
176
+ had_profile = profile_name in settings.profiles
177
+ if had_profile:
178
+ services.settings.save(settings.without_profile(profile_name))
179
+ if not removed and not had_profile:
180
+ message = f"No stored credentials for profile '{profile_name}'."
181
+ raise CliError(message, exit_code=ExitCode.CONFIGURATION)
182
+ app_context.notify(f"Logged out of profile '{profile_name}'.")
183
+ provider = app_context.options.profile.provider
184
+ if services.credentials.environment.get(profile_name, provider) is not None:
185
+ variable = spec_for(provider).api_key_env_var
186
+ app_context.notify(f"{variable} is still set in the environment and will keep being used.")
187
+
188
+
189
+ @APP.command(help="Print the raw API key of the active credential.")
190
+ @handle_errors
191
+ def token(ctx: typer.Context) -> None:
192
+ resolved = get_app_context(ctx).credential()
193
+ typer.echo(resolved.credential.api_key)