elva-cli 0.2.0__tar.gz → 0.4.0__tar.gz

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 (82) hide show
  1. {elva_cli-0.2.0 → elva_cli-0.4.0}/PKG-INFO +120 -3
  2. {elva_cli-0.2.0 → elva_cli-0.4.0}/README.md +115 -2
  3. {elva_cli-0.2.0 → elva_cli-0.4.0}/pyproject.toml +7 -0
  4. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/_version.py +2 -2
  5. elva_cli-0.4.0/src/elva_cli/auth/__init__.py +49 -0
  6. elva_cli-0.4.0/src/elva_cli/auth/models.py +100 -0
  7. elva_cli-0.4.0/src/elva_cli/auth/session.py +358 -0
  8. elva_cli-0.4.0/src/elva_cli/auth/store.py +121 -0
  9. elva_cli-0.4.0/src/elva_cli/commands/auth.py +37 -0
  10. elva_cli-0.4.0/src/elva_cli/commands/import_.py +94 -0
  11. elva_cli-0.4.0/src/elva_cli/commands/whoami.py +18 -0
  12. elva_cli-0.4.0/src/elva_cli/core/api/http.py +213 -0
  13. elva_cli-0.4.0/src/elva_cli/core/api/targets.py +132 -0
  14. elva_cli-0.4.0/src/elva_cli/core/services/auth.py +197 -0
  15. elva_cli-0.4.0/src/elva_cli/core/services/auth_result.py +40 -0
  16. elva_cli-0.4.0/src/elva_cli/core/services/import_result.py +56 -0
  17. elva_cli-0.4.0/src/elva_cli/core/services/import_spec.py +493 -0
  18. elva_cli-0.4.0/src/elva_cli/core/services/whoami.py +80 -0
  19. elva_cli-0.4.0/src/elva_cli/core/services/whoami_result.py +12 -0
  20. elva_cli-0.4.0/src/elva_cli/core/spec/detect.py +118 -0
  21. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/registry.py +3 -0
  22. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/__init__.py +3 -0
  23. elva_cli-0.4.0/src/elva_cli/ui/renderables/auth.py +25 -0
  24. elva_cli-0.4.0/src/elva_cli/ui/renderables/import_spec.py +92 -0
  25. elva_cli-0.4.0/src/elva_cli/ui/renderables/whoami.py +16 -0
  26. elva_cli-0.4.0/tests/cli/conftest.py +66 -0
  27. elva_cli-0.4.0/tests/cli/test_auth_logout_command.py +19 -0
  28. elva_cli-0.4.0/tests/cli/test_import_command.py +146 -0
  29. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/cli/test_never_blocks.py +14 -3
  30. elva_cli-0.4.0/tests/cli/test_whoami_command.py +17 -0
  31. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_context.py +47 -0
  32. elva_cli-0.4.0/tests/unit/test_credentials.py +83 -0
  33. elva_cli-0.4.0/tests/unit/test_http.py +188 -0
  34. elva_cli-0.4.0/tests/unit/test_import_flow.py +753 -0
  35. elva_cli-0.4.0/tests/unit/test_login_flow.py +307 -0
  36. elva_cli-0.4.0/tests/unit/test_logout_flow.py +35 -0
  37. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_output.py +73 -0
  38. elva_cli-0.4.0/tests/unit/test_session.py +788 -0
  39. elva_cli-0.4.0/tests/unit/test_spec_detect.py +175 -0
  40. elva_cli-0.4.0/tests/unit/test_targets.py +147 -0
  41. elva_cli-0.4.0/tests/unit/test_token_store.py +199 -0
  42. elva_cli-0.4.0/tests/unit/test_whoami_flow.py +166 -0
  43. elva_cli-0.2.0/src/elva_cli/auth/__init__.py +0 -8
  44. {elva_cli-0.2.0 → elva_cli-0.4.0}/.gitignore +0 -0
  45. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/__init__.py +0 -0
  46. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/__main__.py +0 -0
  47. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/commands/__init__.py +0 -0
  48. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/commands/config.py +0 -0
  49. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/context.py +0 -0
  50. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/core/__init__.py +0 -0
  51. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/core/api/__init__.py +0 -0
  52. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/core/services/__init__.py +0 -0
  53. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/core/services/config.py +0 -0
  54. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/core/spec/__init__.py +0 -0
  55. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/errors.py +0 -0
  56. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/logging.py +0 -0
  57. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/main.py +0 -0
  58. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/settings/__init__.py +0 -0
  59. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/settings/loader.py +0 -0
  60. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/settings/models.py +0 -0
  61. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/settings/paths.py +0 -0
  62. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/telemetry.py +0 -0
  63. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/__init__.py +0 -0
  64. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/console.py +0 -0
  65. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/output.py +0 -0
  66. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/prompts.py +0 -0
  67. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/base.py +0 -0
  68. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/config.py +0 -0
  69. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/theme.py +0 -0
  70. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/views/__init__.py +0 -0
  71. {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/update.py +0 -0
  72. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/cli/test_cli_exit_codes.py +0 -0
  73. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/cli/test_config_command.py +0 -0
  74. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/cli/test_lazy_imports.py +0 -0
  75. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/cli/test_output_streams.py +0 -0
  76. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_boundary.py +0 -0
  77. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_error_boundary.py +0 -0
  78. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_errors.py +0 -0
  79. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_exit_codes.py +0 -0
  80. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_interactivity.py +0 -0
  81. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_prompts.py +0 -0
  82. {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_settings_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: elva-cli
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Elva - CLI for Theneo Elva
5
5
  Project-URL: Homepage, https://getelva.ai
6
6
  Project-URL: Source, https://github.com/Theneo-Inc/theneo-elva-cli
@@ -16,13 +16,17 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Topic :: Software Development :: Documentation
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Python: >=3.11
19
+ Requires-Dist: keyring>=25
19
20
  Requires-Dist: platformdirs>=4.2
20
21
  Requires-Dist: pydantic>=2.7
22
+ Requires-Dist: pyyaml>=6.0
23
+ Requires-Dist: questionary>=2.0
21
24
  Requires-Dist: typer<1.0,>=0.15
22
25
  Provides-Extra: dev
23
26
  Requires-Dist: mypy>=1.11; extra == 'dev'
24
27
  Requires-Dist: pytest>=8.2; extra == 'dev'
25
28
  Requires-Dist: ruff>=0.6; extra == 'dev'
29
+ Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
26
30
  Description-Content-Type: text/markdown
27
31
 
28
32
  # Elva CLI
@@ -30,8 +34,7 @@ Description-Content-Type: text/markdown
30
34
  Manage your [Elva](https://getelva.ai) API projects from the terminal: import specs,
31
35
  inspect collections, and generate MCP servers without opening a browser.
32
36
 
33
- > **Early alpha.** The command surface is still taking shape. This release ships
34
- > `--version` and `--help` only; the first working commands land in `0.1.0`.
37
+ > **Early alpha.** The command surface is still taking shape.
35
38
 
36
39
  ## Install
37
40
 
@@ -73,6 +76,120 @@ powershell -c "irm https://astral.sh/uv/install.ps1|iex" # Windows
73
76
  uv tool upgrade elva-cli # or: pipx upgrade elva-cli
74
77
  ```
75
78
 
79
+ ## Importing a spec
80
+
81
+ Create a collection from an OpenAPI document:
82
+
83
+ ```bash
84
+ elva import spec openapi.yaml
85
+ ```
86
+
87
+ ```
88
+ Created Payments Platform API from openapi.yaml.
89
+
90
+ workspace Theneo
91
+ format openapi
92
+ title Payments Platform API
93
+ version 2.4.1
94
+ endpoints 17
95
+ collection id 6aa1322d7ef06cc8f9017460
96
+ url https://app.getelva.ai/collections?selected=6aa1322d7ef06cc8f9017460
97
+ ```
98
+
99
+ The collection is named after the spec's `info.title`. Override it with `--name`, which
100
+ is also what you will be asked for if the spec has no title:
101
+
102
+ ```bash
103
+ elva import spec openapi.yaml --name "Payments v2"
104
+ ```
105
+
106
+ ### Where the spec comes from
107
+
108
+ A path, a URL Elva fetches itself, or stdin:
109
+
110
+ ```bash
111
+ elva import spec openapi.yaml
112
+ ```
113
+
114
+ ```bash
115
+ elva import spec --url https://example.com/openapi.yaml --name Payments
116
+ ```
117
+
118
+ ```bash
119
+ curl -s https://example.com/openapi.yaml | elva import spec - --name Payments
120
+ ```
121
+
122
+ A URL is fetched server-side, so nothing is read locally -- which is why `--name` cannot
123
+ be defaulted from it. Files must be `.json`, `.yaml` or `.yml`, and 10 MB or smaller.
124
+
125
+ ### Updating an existing collection
126
+
127
+ Importing never overwrites. Replacing the spec of a collection that already exists is a
128
+ separate, explicit action:
129
+
130
+ ```bash
131
+ elva -c payments-api import spec openapi.yaml --update
132
+ ```
133
+
134
+ The previous spec is kept as a restorable version. `--collection` takes a name or an id,
135
+ and `--workspace` picks which workspace to look that name up in; an account with a single
136
+ workspace needs neither. Both are global flags, so they go before the subcommand, and both
137
+ can live in `elva.json` instead.
138
+
139
+ ### Checking first
140
+
141
+ `--dry-run` reports what would happen and sends nothing at all -- it works signed out:
142
+
143
+ ```bash
144
+ elva import spec openapi.yaml --dry-run
145
+ ```
146
+
147
+ ```
148
+ Would create Payments Platform API from openapi.yaml.
149
+
150
+ format openapi
151
+ title Payments Platform API
152
+ version 2.4.1
153
+ endpoints 17
154
+ size 11.3 KB
155
+
156
+ Nothing was sent. Drop --dry-run to do it.
157
+ ```
158
+
159
+ ### Postman
160
+
161
+ Postman collections are not supported here. Elva reads operations out of an uploaded file
162
+ as OpenAPI, so a Postman collection would import successfully and produce an empty
163
+ collection -- `elva` refuses it rather than let that happen. Export to OpenAPI first, or
164
+ use the Postman integration in the web app.
165
+
166
+ ### In CI
167
+
168
+ The exit code is the whole interface:
169
+
170
+ ```bash
171
+ elva import spec openapi.yaml
172
+ case $? in
173
+ 0) echo "imported" ;;
174
+ 2) echo "bad invocation - wrong name, missing file, name already taken"; exit 1 ;;
175
+ 3) echo "not signed in"; exit 1 ;;
176
+ 4) echo "the spec was rejected"; exit 1 ;;
177
+ 5) echo "Elva unreachable"; exit 0 ;;
178
+ esac
179
+ ```
180
+
181
+ Note what `0` does and does not mean. Elva stores the file and extracts what it can, so a
182
+ spec it cannot parse imports *successfully* with no endpoints in it rather than failing.
183
+ The CLI says so plainly in that case, but the exit code still comes from the server. A
184
+ pipeline that cares should check the count:
185
+
186
+ ```bash
187
+ count=$(elva --json import spec openapi.yaml | jq '.endpoints // 0')
188
+ [ "$count" -gt 0 ] || { echo "spec produced no endpoints"; exit 1; }
189
+ ```
190
+
191
+ See [exit codes](docs/exit-codes.md) for the full table.
192
+
76
193
  ## Configuration
77
194
 
78
195
  Settings can come from several places. Highest priority wins:
@@ -3,8 +3,7 @@
3
3
  Manage your [Elva](https://getelva.ai) API projects from the terminal: import specs,
4
4
  inspect collections, and generate MCP servers without opening a browser.
5
5
 
6
- > **Early alpha.** The command surface is still taking shape. This release ships
7
- > `--version` and `--help` only; the first working commands land in `0.1.0`.
6
+ > **Early alpha.** The command surface is still taking shape.
8
7
 
9
8
  ## Install
10
9
 
@@ -46,6 +45,120 @@ powershell -c "irm https://astral.sh/uv/install.ps1|iex" # Windows
46
45
  uv tool upgrade elva-cli # or: pipx upgrade elva-cli
47
46
  ```
48
47
 
48
+ ## Importing a spec
49
+
50
+ Create a collection from an OpenAPI document:
51
+
52
+ ```bash
53
+ elva import spec openapi.yaml
54
+ ```
55
+
56
+ ```
57
+ Created Payments Platform API from openapi.yaml.
58
+
59
+ workspace Theneo
60
+ format openapi
61
+ title Payments Platform API
62
+ version 2.4.1
63
+ endpoints 17
64
+ collection id 6aa1322d7ef06cc8f9017460
65
+ url https://app.getelva.ai/collections?selected=6aa1322d7ef06cc8f9017460
66
+ ```
67
+
68
+ The collection is named after the spec's `info.title`. Override it with `--name`, which
69
+ is also what you will be asked for if the spec has no title:
70
+
71
+ ```bash
72
+ elva import spec openapi.yaml --name "Payments v2"
73
+ ```
74
+
75
+ ### Where the spec comes from
76
+
77
+ A path, a URL Elva fetches itself, or stdin:
78
+
79
+ ```bash
80
+ elva import spec openapi.yaml
81
+ ```
82
+
83
+ ```bash
84
+ elva import spec --url https://example.com/openapi.yaml --name Payments
85
+ ```
86
+
87
+ ```bash
88
+ curl -s https://example.com/openapi.yaml | elva import spec - --name Payments
89
+ ```
90
+
91
+ A URL is fetched server-side, so nothing is read locally -- which is why `--name` cannot
92
+ be defaulted from it. Files must be `.json`, `.yaml` or `.yml`, and 10 MB or smaller.
93
+
94
+ ### Updating an existing collection
95
+
96
+ Importing never overwrites. Replacing the spec of a collection that already exists is a
97
+ separate, explicit action:
98
+
99
+ ```bash
100
+ elva -c payments-api import spec openapi.yaml --update
101
+ ```
102
+
103
+ The previous spec is kept as a restorable version. `--collection` takes a name or an id,
104
+ and `--workspace` picks which workspace to look that name up in; an account with a single
105
+ workspace needs neither. Both are global flags, so they go before the subcommand, and both
106
+ can live in `elva.json` instead.
107
+
108
+ ### Checking first
109
+
110
+ `--dry-run` reports what would happen and sends nothing at all -- it works signed out:
111
+
112
+ ```bash
113
+ elva import spec openapi.yaml --dry-run
114
+ ```
115
+
116
+ ```
117
+ Would create Payments Platform API from openapi.yaml.
118
+
119
+ format openapi
120
+ title Payments Platform API
121
+ version 2.4.1
122
+ endpoints 17
123
+ size 11.3 KB
124
+
125
+ Nothing was sent. Drop --dry-run to do it.
126
+ ```
127
+
128
+ ### Postman
129
+
130
+ Postman collections are not supported here. Elva reads operations out of an uploaded file
131
+ as OpenAPI, so a Postman collection would import successfully and produce an empty
132
+ collection -- `elva` refuses it rather than let that happen. Export to OpenAPI first, or
133
+ use the Postman integration in the web app.
134
+
135
+ ### In CI
136
+
137
+ The exit code is the whole interface:
138
+
139
+ ```bash
140
+ elva import spec openapi.yaml
141
+ case $? in
142
+ 0) echo "imported" ;;
143
+ 2) echo "bad invocation - wrong name, missing file, name already taken"; exit 1 ;;
144
+ 3) echo "not signed in"; exit 1 ;;
145
+ 4) echo "the spec was rejected"; exit 1 ;;
146
+ 5) echo "Elva unreachable"; exit 0 ;;
147
+ esac
148
+ ```
149
+
150
+ Note what `0` does and does not mean. Elva stores the file and extracts what it can, so a
151
+ spec it cannot parse imports *successfully* with no endpoints in it rather than failing.
152
+ The CLI says so plainly in that case, but the exit code still comes from the server. A
153
+ pipeline that cares should check the count:
154
+
155
+ ```bash
156
+ count=$(elva --json import spec openapi.yaml | jq '.endpoints // 0')
157
+ [ "$count" -gt 0 ] || { echo "spec produced no endpoints"; exit 1; }
158
+ ```
159
+
160
+ See [exit codes](docs/exit-codes.md) for the full table.
161
+
49
162
  ## Configuration
50
163
 
51
164
  Settings can come from several places. Highest priority wins:
@@ -25,6 +25,12 @@ dependencies = [
25
25
  "typer>=0.15,<1.0",
26
26
  "platformdirs>=4.2",
27
27
  "pydantic>=2.7",
28
+ "keyring>=25",
29
+ # Reads info.title out of a spec so --name can default to it, and backs
30
+ # --dry-run's report. Parses JSON too, so one parser covers both formats.
31
+ "pyyaml>=6.0",
32
+ # ui/prompts.py has always imported this; it was simply never declared.
33
+ "questionary>=2.0",
28
34
  ]
29
35
 
30
36
  [project.optional-dependencies]
@@ -32,6 +38,7 @@ dev = [
32
38
  "ruff>=0.6",
33
39
  "mypy>=1.11",
34
40
  "pytest>=8.2",
41
+ "types-PyYAML>=6.0",
35
42
  ]
36
43
 
37
44
  [project.urls]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0'
22
- __version_tuple__ = version_tuple = (0, 2, 0)
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,49 @@
1
+ """Credentials and the login flow.
2
+
3
+ Token storage sits behind a Protocol (keyring, with a 0600 file fallback for
4
+ headless Linux and containers). ELVA_TOKEN overrides both in CI. A session's
5
+ access token refreshes itself transparently on a near-expiry read; a dead
6
+ session raises AuthError (exit code 3).
7
+
8
+ The browser handoff needs a /auth/cli endpoint on the JWT side of the
9
+ backend. The cookie-session auth used by the catalog and GitHub routes is
10
+ deliberately out of scope: a CLI has no cookie jar.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ from elva_cli.auth.models import Credentials
18
+
19
+ if TYPE_CHECKING:
20
+ from elva_cli.auth.session import (
21
+ ENV_TOKEN,
22
+ current_identity,
23
+ forget_stored_credentials,
24
+ get_access_token,
25
+ logout,
26
+ save_login,
27
+ save_pat,
28
+ )
29
+
30
+ __all__ = [
31
+ "ENV_TOKEN",
32
+ "Credentials",
33
+ "current_identity",
34
+ "forget_stored_credentials",
35
+ "get_access_token",
36
+ "logout",
37
+ "save_login",
38
+ "save_pat",
39
+ ]
40
+
41
+ _LAZY = frozenset(__all__) - {"Credentials"}
42
+
43
+
44
+ def __getattr__(name: str) -> object:
45
+ if name in _LAZY:
46
+ from elva_cli.auth import session
47
+
48
+ return getattr(session, name)
49
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,100 @@
1
+ """The Credentials shape: what gets stored, and how it maps to the wire."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from typing import Any, Literal
8
+
9
+ TokenKind = Literal["session", "pat"]
10
+
11
+
12
+ def _parse_expiry(value: str) -> datetime:
13
+ """Parse an ISO-8601 timestamp, forcing it timezone-aware.
14
+
15
+ The backend sends UTC with a `Z`, but a hand-edited credentials.json can
16
+ drop the offset. A naive datetime would later crash get_access_token's
17
+ `expires - datetime.now(UTC)` arithmetic, so assume UTC when none is
18
+ given."""
19
+ parsed = datetime.fromisoformat(value)
20
+ return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Credentials:
25
+ """One stored credential: either an OAuth session (JWT pair, refreshable)
26
+ or a Personal Access Token (opaque, long-lived, no refresh side)."""
27
+
28
+ kind: TokenKind
29
+ access_token: str
30
+ access_expires_at: datetime | None
31
+ refresh_token: str | None
32
+ refresh_expires_at: datetime | None
33
+
34
+ @classmethod
35
+ def from_auth_tokens(cls, tokens: dict[str, Any]) -> Credentials:
36
+ """Build a session credential from the backend's AuthTokens shape:
37
+ {"access": {"token", "expires"}, "refresh": {"token", "expires"}}.
38
+
39
+ POST /api/auth/refresh-tokens returns this shape at the top level.
40
+ The CLI login exchange (POST /api/auth/cli/token) nests it under a
41
+ "tokens" key alongside "user" — callers there go through
42
+ `session.save_login`, which unwraps it first."""
43
+ access = tokens["access"]
44
+ refresh = tokens["refresh"]
45
+ return cls(
46
+ kind="session",
47
+ access_token=access["token"],
48
+ access_expires_at=_parse_expiry(access["expires"]),
49
+ refresh_token=refresh["token"],
50
+ refresh_expires_at=_parse_expiry(refresh["expires"]),
51
+ )
52
+
53
+ @classmethod
54
+ def from_pat(cls, token: str) -> Credentials:
55
+ return cls(
56
+ kind="pat",
57
+ access_token=token,
58
+ access_expires_at=None,
59
+ refresh_token=None,
60
+ refresh_expires_at=None,
61
+ )
62
+
63
+ def to_json(self) -> dict[str, Any]:
64
+ return {
65
+ "kind": self.kind,
66
+ "access_token": self.access_token,
67
+ "access_expires_at": (
68
+ self.access_expires_at.isoformat() if self.access_expires_at else None
69
+ ),
70
+ "refresh_token": self.refresh_token,
71
+ "refresh_expires_at": (
72
+ self.refresh_expires_at.isoformat() if self.refresh_expires_at else None
73
+ ),
74
+ }
75
+
76
+ @classmethod
77
+ def from_json(cls, data: dict[str, Any]) -> Credentials:
78
+ kind = data["kind"]
79
+ if kind not in ("session", "pat"):
80
+ raise ValueError(f"unknown credential kind: {kind!r}")
81
+ creds = cls(
82
+ kind=kind,
83
+ access_token=data["access_token"],
84
+ access_expires_at=(
85
+ _parse_expiry(data["access_expires_at"]) if data.get("access_expires_at") else None
86
+ ),
87
+ refresh_token=data.get("refresh_token"),
88
+ refresh_expires_at=(
89
+ _parse_expiry(data["refresh_expires_at"])
90
+ if data.get("refresh_expires_at")
91
+ else None
92
+ ),
93
+ )
94
+ if creds.kind == "session" and (
95
+ creds.access_expires_at is None
96
+ or creds.refresh_token is None
97
+ or creds.refresh_expires_at is None
98
+ ):
99
+ raise ValueError("incomplete session credential")
100
+ return creds