levain-cli 0.1.0__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.
levain_recipe/auth.py ADDED
@@ -0,0 +1,322 @@
1
+ """Device-flow login, token storage, and refresh for the Levain CLI.
2
+
3
+ Authoring needs a credential for git, which can't do OAuth. Rather
4
+ than have the user keep a long-lived API key on disk — or worse, hand
5
+ one to an agent to paste into commands — the CLI signs in once with
6
+ the OAuth **device authorization grant** and keeps a refresh token.
7
+ Everything after that is derived and short-lived.
8
+
9
+ Device flow rather than a loopback redirect on purpose: developers
10
+ using a coding agent are often inside a container or an SSH session,
11
+ where the browser is on a different machine and binding a local port
12
+ proves nothing. Device flow only needs the user to open a URL
13
+ wherever their browser happens to be.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import json
20
+ import os
21
+ import stat
22
+ import time
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Any
26
+ from urllib.parse import urljoin
27
+
28
+ import httpx
29
+
30
+ DEFAULT_API = "https://api.levainlabs.com"
31
+ DEFAULT_GIT_HOST = "git.levainlabs.com"
32
+
33
+ # Refresh a little early: a token that expires mid-request is
34
+ # indistinguishable from a revoked one to the caller.
35
+ _EXPIRY_SKEW_S = 60
36
+
37
+ _SCOPES = "openid profile email offline_access"
38
+
39
+
40
+ class AuthError(Exception):
41
+ """Login is missing, expired, or the provider refused."""
42
+
43
+
44
+ def api_base() -> str:
45
+ """API root to authenticate against."""
46
+ return os.environ.get("LEVAIN_API", DEFAULT_API).rstrip("/")
47
+
48
+
49
+ def git_host() -> str:
50
+ """Only host the git credential helper may mint credentials for."""
51
+ return os.environ.get("LEVAIN_GIT_HOST", DEFAULT_GIT_HOST)
52
+
53
+
54
+ def _store_path() -> Path:
55
+ root = os.environ.get("LEVAIN_HOME")
56
+ base = Path(root) if root else Path.home() / ".levain"
57
+ return base / "credentials.json"
58
+
59
+
60
+ def _load_store() -> dict[str, Any]:
61
+ path = _store_path()
62
+ try:
63
+ with open(path, encoding="utf-8") as fh:
64
+ data = json.load(fh)
65
+ except (OSError, ValueError):
66
+ return {}
67
+ return data if isinstance(data, dict) else {}
68
+
69
+
70
+ def _save_store(store: dict[str, Any]) -> None:
71
+ """Write the credential store, owner-readable only.
72
+
73
+ Created with restrictive permissions *before* anything is written,
74
+ so the secret is never briefly world-readable on a fresh install.
75
+ """
76
+ path = _store_path()
77
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
78
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
79
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
80
+ json.dump(store, fh, indent=2)
81
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class AuthConfig:
86
+ """Where to authenticate for a given deployment."""
87
+
88
+ authkit_domain: str
89
+ client_id: str
90
+ # Servers that know their identity provider serve the endpoints
91
+ # directly; empty means "fall back to OpenID discovery".
92
+ device_authorization_endpoint: str = ""
93
+ token_endpoint: str = ""
94
+
95
+
96
+ def fetch_auth_config(api: str, client: httpx.Client) -> AuthConfig:
97
+ """Ask the deployment which identity provider to use.
98
+
99
+ Deliberately not baked into the package: a self-hosted install has
100
+ its own AuthKit tenant, and a client that hard-codes ours would
101
+ silently sign people into the wrong place.
102
+ """
103
+ resp = client.get(urljoin(api + "/", "api/v1/authoring/auth-config"))
104
+ if resp.status_code != 200:
105
+ raise AuthError(
106
+ f"{api} did not return a login configuration "
107
+ f"(HTTP {resp.status_code}). Check LEVAIN_API."
108
+ )
109
+ body = resp.json()
110
+ domain = str(body.get("authkit_domain", "")).rstrip("/")
111
+ client_id = str(body.get("client_id", ""))
112
+ if not domain or not client_id:
113
+ raise AuthError("This deployment has no CLI login configured.")
114
+ return AuthConfig(
115
+ authkit_domain=domain,
116
+ client_id=client_id,
117
+ device_authorization_endpoint=str(
118
+ body.get("device_authorization_endpoint", "") or ""
119
+ ),
120
+ token_endpoint=str(body.get("token_endpoint", "") or ""),
121
+ )
122
+
123
+
124
+ def _discover(domain: str, client: httpx.Client) -> dict[str, Any]:
125
+ resp = client.get(f"{domain}/.well-known/openid-configuration")
126
+ if resp.status_code != 200:
127
+ raise AuthError(f"Could not read OpenID configuration from {domain}")
128
+ data: dict[str, Any] = resp.json()
129
+ return data
130
+
131
+
132
+ def _flow_endpoints(
133
+ config: AuthConfig, client: httpx.Client, *, need_device: bool = True
134
+ ) -> tuple[str, str]:
135
+ """The (device authorization, token) endpoints to use.
136
+
137
+ Prefer what the deployment serves: WorkOS accepts AuthKit
138
+ application clients only on its native ``user_management``
139
+ endpoints, while the ones its discovery document advertises
140
+ refuse them. Discovery stays as the fallback for deployments
141
+ that serve no endpoints (any standards-compliant provider).
142
+
143
+ ``need_device`` is False for the refresh path, which only ever
144
+ uses the token endpoint: a standards-compliant provider need not
145
+ advertise ``device_authorization_endpoint`` in its discovery
146
+ document to support refresh.
147
+ """
148
+ if config.device_authorization_endpoint and config.token_endpoint:
149
+ return config.device_authorization_endpoint, config.token_endpoint
150
+ meta = _discover(config.authkit_domain, client)
151
+ device = meta.get("device_authorization_endpoint")
152
+ token = meta.get("token_endpoint")
153
+ if not token or (need_device and not device):
154
+ raise AuthError(
155
+ "This identity provider does not support device login."
156
+ )
157
+ return str(device or ""), str(token)
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class DevicePrompt:
162
+ """What the user has to do to finish signing in."""
163
+
164
+ verification_uri: str
165
+ user_code: str
166
+ interval: int
167
+ device_code: str
168
+ token_endpoint: str
169
+ client_id: str
170
+
171
+
172
+ def start_device_login(api: str, client: httpx.Client) -> DevicePrompt:
173
+ """Begin the device flow; returns what to show the user."""
174
+ config = fetch_auth_config(api, client)
175
+ endpoint, token_endpoint = _flow_endpoints(config, client)
176
+ resp = client.post(
177
+ endpoint,
178
+ data={"client_id": config.client_id, "scope": _SCOPES},
179
+ )
180
+ if resp.status_code != 200:
181
+ raise AuthError(f"Could not start login (HTTP {resp.status_code}).")
182
+ body = resp.json()
183
+ device_code = body.get("device_code")
184
+ if not device_code:
185
+ raise AuthError(
186
+ "This identity provider returned an incomplete response."
187
+ )
188
+ return DevicePrompt(
189
+ verification_uri=str(
190
+ body.get("verification_uri_complete")
191
+ or body.get("verification_uri", "")
192
+ ),
193
+ user_code=str(body.get("user_code", "")),
194
+ interval=int(body.get("interval", 5) or 5),
195
+ device_code=str(device_code),
196
+ token_endpoint=token_endpoint,
197
+ client_id=config.client_id,
198
+ )
199
+
200
+
201
+ def poll_device_login(
202
+ api: str,
203
+ prompt: DevicePrompt,
204
+ client: httpx.Client,
205
+ *,
206
+ timeout_s: int = 600,
207
+ ) -> None:
208
+ """Poll until the user approves, then store the tokens.
209
+
210
+ Honours the provider's back-off signals rather than hammering:
211
+ ``authorization_pending`` means keep waiting, ``slow_down`` means
212
+ wait longer, anything else is terminal.
213
+ """
214
+ deadline = time.time() + timeout_s
215
+ interval = prompt.interval
216
+ while time.time() < deadline:
217
+ time.sleep(interval)
218
+ resp = client.post(
219
+ prompt.token_endpoint,
220
+ data={
221
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
222
+ "device_code": prompt.device_code,
223
+ "client_id": prompt.client_id,
224
+ },
225
+ )
226
+ if resp.status_code == 200:
227
+ _store_tokens(api, resp.json())
228
+ return
229
+ error = ""
230
+ try:
231
+ error = str(resp.json().get("error", ""))
232
+ except ValueError:
233
+ pass
234
+ if error == "authorization_pending":
235
+ continue
236
+ if error == "slow_down":
237
+ interval += 5
238
+ continue
239
+ if error == "expired_token":
240
+ raise AuthError("The login request expired. Run it again.")
241
+ if error == "access_denied":
242
+ raise AuthError("Login was declined.")
243
+ raise AuthError(f"Login failed ({error or resp.status_code}).")
244
+ raise AuthError("Timed out waiting for approval.")
245
+
246
+
247
+ def _jwt_exp(token: str) -> int:
248
+ """Best-effort ``exp`` claim of a JWT; 0 when unreadable."""
249
+ try:
250
+ payload = token.split(".")[1]
251
+ padded = payload + "=" * (-len(payload) % 4)
252
+ claims = json.loads(base64.urlsafe_b64decode(padded))
253
+ return int(claims.get("exp", 0))
254
+ except (ValueError, IndexError, TypeError, AttributeError):
255
+ return 0
256
+
257
+
258
+ def _store_tokens(api: str, payload: dict[str, Any]) -> None:
259
+ access = str(payload.get("access_token", ""))
260
+ expires_in = payload.get("expires_in")
261
+ if expires_in is not None:
262
+ expires_at = int(time.time()) + int(expires_in)
263
+ else:
264
+ # WorkOS's native token responses carry no ``expires_in``;
265
+ # the JWT's own ``exp`` is authoritative. Unreadable means
266
+ # treat as already expired, so the next call refreshes.
267
+ expires_at = _jwt_exp(access)
268
+ store = _load_store()
269
+ store[api] = {
270
+ "access_token": access,
271
+ "refresh_token": payload.get("refresh_token", ""),
272
+ "expires_at": expires_at,
273
+ }
274
+ _save_store(store)
275
+
276
+
277
+ def logout(api: str) -> bool:
278
+ """Forget the stored credential. True when there was one."""
279
+ store = _load_store()
280
+ if api not in store:
281
+ return False
282
+ del store[api]
283
+ _save_store(store)
284
+ return True
285
+
286
+
287
+ def access_token(api: str, client: httpx.Client) -> str:
288
+ """A valid access token, refreshing if needed.
289
+
290
+ Raises :class:`AuthError` when the user has never logged in or the
291
+ refresh token is no longer accepted — both mean "run levain
292
+ login", which is what the caller tells them.
293
+ """
294
+ entry = _load_store().get(api)
295
+ if not entry:
296
+ raise AuthError(f"Not logged in to {api}. Run: levain login")
297
+ if int(entry.get("expires_at", 0)) - _EXPIRY_SKEW_S > time.time():
298
+ token: str = entry.get("access_token", "")
299
+ if token:
300
+ return token
301
+ refresh = entry.get("refresh_token", "")
302
+ if not refresh:
303
+ raise AuthError(f"Session expired for {api}. Run: levain login")
304
+
305
+ config = fetch_auth_config(api, client)
306
+ _, token_endpoint = _flow_endpoints(config, client, need_device=False)
307
+ resp = client.post(
308
+ token_endpoint,
309
+ data={
310
+ "grant_type": "refresh_token",
311
+ "refresh_token": refresh,
312
+ "client_id": config.client_id,
313
+ },
314
+ )
315
+ if resp.status_code != 200:
316
+ raise AuthError(f"Session expired for {api}. Run: levain login")
317
+ payload = resp.json()
318
+ # Providers may or may not rotate the refresh token; keep the old
319
+ # one when none comes back, or the next refresh has nothing to use.
320
+ payload.setdefault("refresh_token", refresh)
321
+ _store_tokens(api, payload)
322
+ return str(payload.get("access_token", ""))
levain_recipe/cli.py ADDED
@@ -0,0 +1,344 @@
1
+ """``levain`` — CLI for the Levain recipe toolchain.
2
+
3
+ Usage:
4
+ levain validate <file>
5
+ levain validate-package <dir>
6
+ levain generate-state <dir>
7
+ levain mcp
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import shutil
14
+ import sys
15
+ from importlib.metadata import PackageNotFoundError
16
+ from importlib.metadata import version as pkg_version
17
+ from pathlib import Path
18
+ from typing import Annotated, Any
19
+
20
+ import typer
21
+
22
+ from . import LANGUAGE_VERSION
23
+ from ._format import format_errors, format_success
24
+ from ._java import ensure_jar
25
+ from .codegen import generate_state_module
26
+ from .validate import validate, validate_fast, validate_package
27
+
28
+ app = typer.Typer(
29
+ name="levain",
30
+ help="PlusCal spec validation for agent recipes",
31
+ add_completion=False,
32
+ )
33
+
34
+ _NO_JAVA_NOTE = (
35
+ "Java not found — stages 4-5 (TLC model checking) skipped; "
36
+ "they still run server-side on push."
37
+ )
38
+
39
+
40
+ def _version_callback(value: bool) -> None:
41
+ if value:
42
+ try:
43
+ tool = pkg_version("levain-cli")
44
+ except PackageNotFoundError:
45
+ tool = "dev"
46
+ print(f"levain-cli {tool} (recipe language {LANGUAGE_VERSION})")
47
+ raise typer.Exit()
48
+
49
+
50
+ @app.callback()
51
+ def _main(
52
+ version: Annotated[
53
+ bool,
54
+ typer.Option(
55
+ "--version",
56
+ callback=_version_callback,
57
+ is_eager=True,
58
+ help="Print tool and recipe-language versions.",
59
+ ),
60
+ ] = False,
61
+ ) -> None:
62
+ """Levain recipe toolchain."""
63
+
64
+
65
+ def _prepare_tlc() -> bool:
66
+ """Set up TLC (stages 4-5) if Java is available.
67
+
68
+ Returns True when full validation can run; prints the
69
+ degradation note and returns False otherwise.
70
+ """
71
+ if shutil.which("java") is None:
72
+ print(_NO_JAVA_NOTE, file=sys.stderr)
73
+ return False
74
+ ensure_jar()
75
+ return True
76
+
77
+
78
+ # ── Commands ──────────────────────────────────────────
79
+
80
+
81
+ @app.command("validate")
82
+ def validate_cmd(
83
+ file: Annotated[
84
+ Path,
85
+ typer.Argument(help="Path to .tla file"),
86
+ ],
87
+ ) -> None:
88
+ """Validate a PlusCal spec file."""
89
+ if not file.is_file():
90
+ print(f"Error: file not found: {file}", file=sys.stderr)
91
+ raise typer.Exit(2)
92
+
93
+ source = file.read_text(encoding="utf-8")
94
+
95
+ if _prepare_tlc():
96
+ result = validate(source)
97
+ else:
98
+ result = validate_fast(source)
99
+
100
+ if isinstance(result, list):
101
+ print(format_errors(result))
102
+ raise typer.Exit(1)
103
+
104
+ print(format_success(result))
105
+
106
+
107
+ @app.command("validate-package")
108
+ def validate_package_cmd(
109
+ recipe_dir: Annotated[
110
+ Path,
111
+ typer.Argument(help="Path to recipe package directory"),
112
+ ],
113
+ ) -> None:
114
+ """Validate a full recipe package (graph.tla + Python source)."""
115
+ if not recipe_dir.is_dir():
116
+ print(
117
+ f"Error: not a directory: {recipe_dir}",
118
+ file=sys.stderr,
119
+ )
120
+ raise typer.Exit(2)
121
+
122
+ _prepare_tlc()
123
+ result = validate_package(str(recipe_dir))
124
+ if isinstance(result, list):
125
+ print(format_errors(result))
126
+ raise typer.Exit(1)
127
+
128
+ print(format_success(result))
129
+
130
+
131
+ @app.command("generate-state")
132
+ def generate_state_cmd(
133
+ recipe_dir: Annotated[
134
+ Path,
135
+ typer.Argument(help="Path to recipe package directory"),
136
+ ],
137
+ ) -> None:
138
+ """Generate the GraphState TypedDict for a recipe.
139
+
140
+ Writes ``_state.py`` into the recipe's Python source
141
+ directory. Fails if the recipe does not validate.
142
+ """
143
+ if not recipe_dir.is_dir():
144
+ print(
145
+ f"Error: not a directory: {recipe_dir}",
146
+ file=sys.stderr,
147
+ )
148
+ raise typer.Exit(2)
149
+
150
+ _prepare_tlc()
151
+ result = validate_package(str(recipe_dir))
152
+ if isinstance(result, list):
153
+ print(format_errors(result))
154
+ raise typer.Exit(1)
155
+
156
+ src_dir = recipe_dir / "src" / result.module_name
157
+ if not src_dir.is_dir():
158
+ print(
159
+ f"Error: expected src dir at {src_dir}",
160
+ file=sys.stderr,
161
+ )
162
+ raise typer.Exit(2)
163
+
164
+ state_path = src_dir / "_state.py"
165
+ state_path.write_text(generate_state_module(result), encoding="utf-8")
166
+ print(f"Wrote {state_path}")
167
+
168
+
169
+ @app.command("mcp")
170
+ def mcp_cmd() -> None:
171
+ """Serve the toolchain over stdio MCP.
172
+
173
+ For MCP clients that cannot run shell commands (e.g. Claude
174
+ Desktop); Claude Code should call the CLI directly.
175
+ """
176
+ try:
177
+ from .mcp import server
178
+ except ImportError:
179
+ print(
180
+ "MCP support needs the extra: pip install 'levain-cli[mcp]'",
181
+ file=sys.stderr,
182
+ )
183
+ raise typer.Exit(2) from None
184
+ server.run()
185
+
186
+
187
+ if __name__ == "__main__":
188
+ app()
189
+
190
+
191
+ # ── Authentication ────────────────────────────────────────
192
+
193
+
194
+ def _client() -> Any:
195
+ import httpx
196
+
197
+ return httpx.Client(timeout=30.0, follow_redirects=True)
198
+
199
+
200
+ @app.command("login")
201
+ def login_cmd() -> None:
202
+ """Sign in to Levain in a browser.
203
+
204
+ Uses the device flow: open the URL shown, enter the code, and the
205
+ CLI stores a refresh token so git operations can mint their own
206
+ short-lived credentials.
207
+ """
208
+ from .auth import (
209
+ AuthError,
210
+ api_base,
211
+ poll_device_login,
212
+ start_device_login,
213
+ )
214
+
215
+ api = api_base()
216
+ try:
217
+ with _client() as client:
218
+ prompt = start_device_login(api, client)
219
+ print(f"Open: {prompt.verification_uri}")
220
+ if prompt.user_code:
221
+ print(f"Code: {prompt.user_code}")
222
+ print("Waiting for approval...", flush=True)
223
+ poll_device_login(api, prompt, client)
224
+ except AuthError as exc:
225
+ print(f"Error: {exc}", file=sys.stderr)
226
+ raise typer.Exit(1) from None
227
+ print(f"Logged in to {api}.")
228
+
229
+
230
+ @app.command("logout")
231
+ def logout_cmd() -> None:
232
+ """Forget the stored Levain credentials."""
233
+ from .auth import api_base, logout
234
+
235
+ api = api_base()
236
+ print(
237
+ f"Logged out of {api}." if logout(api) else f"Not logged in to {api}."
238
+ )
239
+
240
+
241
+ @app.command("whoami")
242
+ def whoami_cmd() -> None:
243
+ """Show who the stored credentials belong to."""
244
+ from .auth import AuthError, access_token, api_base
245
+
246
+ api = api_base()
247
+ try:
248
+ with _client() as client:
249
+ token = access_token(api, client)
250
+ resp = client.get(
251
+ f"{api}/api/v1/auth/me",
252
+ headers={"Authorization": f"Bearer {token}"},
253
+ )
254
+ except AuthError as exc:
255
+ print(f"Error: {exc}", file=sys.stderr)
256
+ raise typer.Exit(1) from None
257
+ if resp.status_code != 200:
258
+ print(f"Error: {api} rejected the session.", file=sys.stderr)
259
+ raise typer.Exit(1)
260
+ print(json.dumps(resp.json(), indent=2))
261
+
262
+
263
+ @app.command("git-credential")
264
+ def git_credential_cmd(
265
+ operation: Annotated[
266
+ str,
267
+ typer.Argument(help="get, store, or erase (git's helper protocol)"),
268
+ ],
269
+ ) -> None:
270
+ r"""Git credential helper for Levain recipe repositories.
271
+
272
+ Wire it up once and git handles the rest:
273
+
274
+ git clone -c credential.helper='!levain git-credential' \
275
+ -c credential.useHttpPath=true <remote>
276
+
277
+ On ``get`` this mints a credential scoped to the repository git
278
+ asked for, valid for about an hour, so nothing long-lived is ever
279
+ written to ``.git/config``. ``store`` and ``erase`` are no-ops —
280
+ there is nothing to persist, and the credential expires on its own.
281
+ """
282
+ request = sys.stdin.read()
283
+ if operation != "get":
284
+ return
285
+
286
+ fields: dict[str, str] = {}
287
+ for line in request.splitlines():
288
+ key, _, value = line.partition("=")
289
+ if key:
290
+ fields[key.strip()] = value.strip()
291
+
292
+ from .auth import AuthError, access_token, api_base, git_host
293
+
294
+ # credential.helper is configured repo-wide (see the docstring
295
+ # above), so git will invoke us for *any* credential request made
296
+ # inside that repo — e.g. a submodule remote — not just the one we
297
+ # were cloned from. Refuse anything that isn't our own git host
298
+ # before minting a real credential for it.
299
+ if fields.get("protocol") != "https" or fields.get("host") != git_host():
300
+ print(
301
+ "levain git-credential: not a Levain recipe URL",
302
+ file=sys.stderr,
303
+ )
304
+ raise typer.Exit(1)
305
+
306
+ # git gives us the repo it wants, so the credential is scoped to
307
+ # exactly that rather than to the whole workspace.
308
+ slug = _slug_from_git_path(fields.get("path", ""))
309
+ if not slug:
310
+ print(
311
+ "levain git-credential: not a Levain recipe URL",
312
+ file=sys.stderr,
313
+ )
314
+ raise typer.Exit(1)
315
+
316
+ api = api_base()
317
+ try:
318
+ with _client() as client:
319
+ token = access_token(api, client)
320
+ resp = client.post(
321
+ f"{api}/api/v1/agents/{slug}/git-credential",
322
+ headers={"Authorization": f"Bearer {token}"},
323
+ )
324
+ except AuthError as exc:
325
+ print(f"levain git-credential: {exc}", file=sys.stderr)
326
+ raise typer.Exit(1) from None
327
+ if resp.status_code != 200:
328
+ print(
329
+ f"levain git-credential: could not mint a credential for "
330
+ f"{slug} (HTTP {resp.status_code})",
331
+ file=sys.stderr,
332
+ )
333
+ raise typer.Exit(1)
334
+ body = resp.json()
335
+ print(f"username={body.get('username', 'x')}")
336
+ print(f"password={body['password']}")
337
+
338
+
339
+ def _slug_from_git_path(path: str) -> str:
340
+ """Agent slug from a recipe repo path (``ws-<hex>/<slug>.git``)."""
341
+ parts = path.strip("/").split("/")
342
+ if len(parts) != 2 or not parts[0].startswith("ws-"):
343
+ return ""
344
+ return parts[1].removesuffix(".git")