klavex 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.
klavex/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
klavex/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from klavex.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
klavex/api.py ADDED
@@ -0,0 +1,166 @@
1
+ """Backend HTTP client.
2
+
3
+ Endpoints used by v0.1:
4
+ POST /cli/device-code (login --device)
5
+ POST /cli/token (login: exchange code for cli_token)
6
+ DELETE /cli/tokens/me (logout, server-side revoke)
7
+ GET /auth/me (whoami)
8
+
9
+ Endpoints used by later versions:
10
+ GET /projects (v0.2)
11
+ GET /projects/{id}/environments (v0.2)
12
+ GET /environments/{id}/variables (v0.2 — names only client-side)
13
+ POST /environments/{id}/variables:reveal (v0.3 — does not exist yet)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ import httpx
22
+
23
+ from . import __version__
24
+ from .errors import (
25
+ NetworkError,
26
+ NotAuthenticated,
27
+ NotFound,
28
+ PermissionDenied,
29
+ KlavexError,
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class CliTokenResponse:
35
+ cli_token: str
36
+ expires_at: str | None
37
+ user: dict[str, Any]
38
+
39
+
40
+ @dataclass
41
+ class DeviceCodeResponse:
42
+ device_code: str
43
+ user_code: str
44
+ verification_uri: str
45
+ interval: int # seconds between polls
46
+ expires_in: int
47
+
48
+
49
+ class ApiClient:
50
+ def __init__(self, base_url: str, token: str | None = None, timeout: float = 10.0) -> None:
51
+ headers = {"User-Agent": f"klavex-cli/{__version__}"}
52
+ if token:
53
+ headers["Authorization"] = f"Bearer {token}"
54
+ self._client = httpx.Client(
55
+ base_url=base_url,
56
+ headers=headers,
57
+ timeout=httpx.Timeout(timeout, connect=5.0),
58
+ )
59
+
60
+ def __enter__(self) -> ApiClient:
61
+ return self
62
+
63
+ def __exit__(self, *exc: object) -> None:
64
+ self._client.close()
65
+
66
+ def close(self) -> None:
67
+ self._client.close()
68
+
69
+ # ---- request helpers --------------------------------------------------
70
+
71
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
72
+ try:
73
+ response = self._client.request(method, path, **kwargs)
74
+ except httpx.HTTPError as exc:
75
+ raise NetworkError(f"Could not reach {self._client.base_url}: {exc}") from exc
76
+
77
+ if response.status_code == 401:
78
+ raise NotAuthenticated()
79
+ if response.status_code == 403:
80
+ raise PermissionDenied(_extract_detail(response) or "Permission denied")
81
+ if response.status_code == 404:
82
+ raise NotFound(_extract_detail(response) or "Not found")
83
+ if response.status_code >= 400:
84
+ raise KlavexError(
85
+ _extract_detail(response) or f"HTTP {response.status_code} from {path}"
86
+ )
87
+ return response
88
+
89
+ # ---- v0.1 endpoints ---------------------------------------------------
90
+
91
+ def exchange_cli_code(self, code: str, code_verifier: str) -> CliTokenResponse:
92
+ r = self._request(
93
+ "POST",
94
+ "/cli/token",
95
+ json={"code": code, "code_verifier": code_verifier},
96
+ )
97
+ data = r.json()
98
+ return CliTokenResponse(
99
+ cli_token=data["cli_token"],
100
+ expires_at=data.get("expires_at"),
101
+ user=data.get("user", {}),
102
+ )
103
+
104
+ def request_device_code(self, device_name: str) -> DeviceCodeResponse:
105
+ r = self._request("POST", "/cli/device-code", json={"device_name": device_name})
106
+ data = r.json()
107
+ return DeviceCodeResponse(
108
+ device_code=data["device_code"],
109
+ user_code=data["user_code"],
110
+ verification_uri=data["verification_uri"],
111
+ interval=int(data.get("interval", 5)),
112
+ expires_in=int(data.get("expires_in", 600)),
113
+ )
114
+
115
+ def poll_device_code(self, device_code: str) -> CliTokenResponse | None:
116
+ """Returns the token once approved, or None if still pending."""
117
+ r = self._client.post("/cli/token", json={"device_code": device_code})
118
+ if r.status_code == 202: # still pending
119
+ return None
120
+ if r.status_code >= 400:
121
+ raise KlavexError(_extract_detail(r) or f"Device polling failed: {r.status_code}")
122
+ data = r.json()
123
+ return CliTokenResponse(
124
+ cli_token=data["cli_token"],
125
+ expires_at=data.get("expires_at"),
126
+ user=data.get("user", {}),
127
+ )
128
+
129
+ def revoke_current_token(self) -> None:
130
+ self._request("DELETE", "/cli/tokens/me")
131
+
132
+ def me(self) -> dict[str, Any]:
133
+ data: dict[str, Any] = self._request("GET", "/auth/me").json()
134
+ return data
135
+
136
+ # ---- v0.2 / v0.3 read endpoints ----
137
+
138
+ def list_projects(self) -> list[dict[str, Any]]:
139
+ data: list[dict[str, Any]] = self._request("GET", "/projects").json()
140
+ return data
141
+
142
+ def list_environments(self, project_id: str) -> list[dict[str, Any]]:
143
+ data: list[dict[str, Any]] = self._request(
144
+ "GET", f"/projects/{project_id}/environments"
145
+ ).json()
146
+ return data
147
+
148
+ def list_variables(self, environment_id: str) -> list[dict[str, Any]]:
149
+ """Returns variables INCLUDING values. Caller must avoid leaking values."""
150
+ data: list[dict[str, Any]] = self._request(
151
+ "GET", f"/environments/{environment_id}/variables"
152
+ ).json()
153
+ return data
154
+
155
+
156
+ def _extract_detail(response: httpx.Response) -> str | None:
157
+ try:
158
+ body = response.json()
159
+ except ValueError:
160
+ return response.text or None
161
+ if isinstance(body, dict):
162
+ for key in ("detail", "message", "error"):
163
+ value = body.get(key)
164
+ if isinstance(value, str):
165
+ return value
166
+ return None
klavex/auth.py ADDED
@@ -0,0 +1,211 @@
1
+ """Browser-callback and device-code login flows.
2
+
3
+ Browser flow (primary):
4
+ 1. Bind 127.0.0.1:<random_port>. Mint state + PKCE verifier.
5
+ 2. Open browser to <dashboard>/cli/authorize?callback=...&state=...&code_challenge=...
6
+ 3. User approves in dashboard. Dashboard mints a one-shot code, redirects to callback.
7
+ 4. We verify state, exchange code+verifier at POST /cli/token, get back a long-lived cli_token.
8
+ 5. Token goes into the OS keychain. Browser tab shows "you can close this tab".
9
+
10
+ Device-code flow (--device, for headless boxes):
11
+ Standard OAuth 2.0 device authorization grant. Poll /cli/token until approved.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import http.server
19
+ import secrets
20
+ import socket
21
+ import threading
22
+ import time
23
+ import urllib.parse
24
+ import webbrowser
25
+ from dataclasses import dataclass
26
+ from typing import Any
27
+
28
+ from .api import ApiClient, CliTokenResponse, DeviceCodeResponse
29
+ from .errors import AuthFlowError, AuthTimeout, StateMismatch
30
+
31
+ # ---------- PKCE -----------------------------------------------------------
32
+
33
+
34
+ def _b64url(data: bytes) -> str:
35
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
36
+
37
+
38
+ def make_pkce_pair() -> tuple[str, str]:
39
+ """(verifier, challenge) — verifier stays in this process; challenge goes to the dashboard."""
40
+ verifier = _b64url(secrets.token_bytes(32))
41
+ challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
42
+ return verifier, challenge
43
+
44
+
45
+ # ---------- Loopback HTTP server ------------------------------------------
46
+
47
+
48
+ def _free_port() -> int:
49
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
50
+ s.bind(("127.0.0.1", 0))
51
+ port: int = s.getsockname()[1]
52
+ return port
53
+
54
+
55
+ _SUCCESS_HTML = b"""<!doctype html>
56
+ <html><head><meta charset="utf-8"><title>Klavex CLI</title>
57
+ <style>
58
+ body { font-family: -apple-system, system-ui, sans-serif; padding: 4rem; max-width: 32rem; margin: 0 auto; }
59
+ h1 { font-size: 1.25rem; }
60
+ p { color: #555; }
61
+ </style></head>
62
+ <body><h1>You're signed in.</h1><p>You can close this tab and return to your terminal.</p></body></html>
63
+ """
64
+
65
+ _ERROR_HTML = b"""<!doctype html>
66
+ <html><head><meta charset="utf-8"><title>Klavex CLI</title></head>
67
+ <body><h1>Login failed.</h1><p>Return to your terminal for details.</p></body></html>
68
+ """
69
+
70
+
71
+ @dataclass
72
+ class _CallbackResult:
73
+ code: str | None = None
74
+ state: str | None = None
75
+ error: str | None = None
76
+
77
+
78
+ class _CallbackServer(http.server.HTTPServer):
79
+ """One-shot HTTP server. Records the first /callback hit, then stops accepting requests."""
80
+
81
+ def __init__(self, port: int) -> None:
82
+ self.result = _CallbackResult()
83
+ self.received = threading.Event()
84
+ super().__init__(("127.0.0.1", port), _CallbackHandler)
85
+
86
+
87
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
88
+ server: _CallbackServer
89
+
90
+ def do_GET(self) -> None:
91
+ parsed = urllib.parse.urlparse(self.path)
92
+ if parsed.path != "/callback":
93
+ self.send_response(404)
94
+ self.end_headers()
95
+ return
96
+
97
+ if self.server.received.is_set():
98
+ self.send_response(409)
99
+ self.end_headers()
100
+ return
101
+
102
+ params = urllib.parse.parse_qs(parsed.query)
103
+ result = self.server.result
104
+ result.code = _first(params.get("code"))
105
+ result.state = _first(params.get("state"))
106
+ result.error = _first(params.get("error"))
107
+
108
+ if result.error or not result.code:
109
+ self.send_response(400)
110
+ self.send_header("Content-Type", "text/html; charset=utf-8")
111
+ self.end_headers()
112
+ self.wfile.write(_ERROR_HTML)
113
+ else:
114
+ self.send_response(200)
115
+ self.send_header("Content-Type", "text/html; charset=utf-8")
116
+ self.end_headers()
117
+ self.wfile.write(_SUCCESS_HTML)
118
+
119
+ self.server.received.set()
120
+
121
+ def log_message(self, format: str, *args: Any) -> None:
122
+ # Silence the default stderr access log.
123
+ return
124
+
125
+
126
+ def _first(values: list[str] | None) -> str | None:
127
+ return values[0] if values else None
128
+
129
+
130
+ # ---------- Browser flow ---------------------------------------------------
131
+
132
+
133
+ def browser_login(
134
+ api: ApiClient,
135
+ dashboard_url: str,
136
+ timeout_seconds: int = 120,
137
+ open_browser: bool = True,
138
+ ) -> CliTokenResponse:
139
+ """Run the browser-callback login flow. Returns a CLI token response on success."""
140
+ state = secrets.token_urlsafe(32)
141
+ verifier, challenge = make_pkce_pair()
142
+ port = _free_port()
143
+ callback = f"http://127.0.0.1:{port}/callback"
144
+
145
+ authorize_url = (
146
+ dashboard_url.rstrip("/")
147
+ + "/cli/authorize?"
148
+ + urllib.parse.urlencode(
149
+ {
150
+ "callback": callback,
151
+ "state": state,
152
+ "code_challenge": challenge,
153
+ "code_challenge_method": "S256",
154
+ "device_name": socket.gethostname(),
155
+ }
156
+ )
157
+ )
158
+
159
+ server = _CallbackServer(port)
160
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
161
+ thread.start()
162
+ try:
163
+ browser_opened = webbrowser.open(authorize_url) if open_browser else False
164
+ if not browser_opened:
165
+ print(f"Open this URL to continue:\n {authorize_url}")
166
+
167
+ if not server.received.wait(timeout=timeout_seconds):
168
+ raise AuthTimeout(timeout_seconds)
169
+ finally:
170
+ server.shutdown()
171
+ thread.join(timeout=2)
172
+ server.server_close()
173
+
174
+ result = server.result
175
+ if result.error:
176
+ raise AuthFlowError(f"Login failed: {result.error}")
177
+ if not result.code:
178
+ raise AuthFlowError("Login failed: no code in callback")
179
+ if result.state != state:
180
+ raise StateMismatch()
181
+
182
+ return api.exchange_cli_code(code=result.code, code_verifier=verifier)
183
+
184
+
185
+ # ---------- Device-code flow ----------------------------------------------
186
+
187
+
188
+ def device_login(
189
+ api: ApiClient,
190
+ open_browser: bool = True,
191
+ poll_max_seconds: int | None = None,
192
+ ) -> CliTokenResponse:
193
+ """Run the device-code login flow. Polls until approved or expiry."""
194
+ device: DeviceCodeResponse = api.request_device_code(device_name=socket.gethostname())
195
+
196
+ print(f"Visit: {device.verification_uri}")
197
+ print(f"Code: {device.user_code}")
198
+ print()
199
+ if open_browser:
200
+ webbrowser.open(device.verification_uri)
201
+
202
+ deadline = time.time() + min(device.expires_in, poll_max_seconds or device.expires_in)
203
+ interval = max(1, device.interval)
204
+
205
+ while time.time() < deadline:
206
+ token = api.poll_device_code(device.device_code)
207
+ if token is not None:
208
+ return token
209
+ time.sleep(interval)
210
+
211
+ raise AuthTimeout(device.expires_in)
klavex/cli.py ADDED
@@ -0,0 +1,98 @@
1
+ """Top-level Typer app. Maps custom exceptions to deterministic exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import traceback
7
+
8
+ import typer
9
+
10
+ from . import __version__
11
+ from .commands.envs import envs
12
+ from .commands.login import login
13
+ from .commands.logout import logout
14
+ from .commands.projects import projects
15
+ from .commands.run import run
16
+ from .commands.status import status
17
+ from .commands.vars import list_vars
18
+ from .commands.whoami import whoami
19
+ from .errors import KlavexError
20
+
21
+ app = typer.Typer(
22
+ name="klavex",
23
+ help="Pull environment variables into a process without writing secrets to disk.",
24
+ no_args_is_help=True,
25
+ add_completion=True,
26
+ )
27
+
28
+
29
+ def _version_callback(value: bool) -> None:
30
+ if value:
31
+ typer.echo(f"klavex {__version__}")
32
+ raise typer.Exit()
33
+
34
+
35
+ @app.callback()
36
+ def _root(
37
+ version: bool | None = typer.Option(
38
+ None, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
39
+ ),
40
+ debug: bool = typer.Option(False, "--debug", help="Print full tracebacks on errors."),
41
+ ) -> None:
42
+ """Root options. --debug is read from sys.argv by the error handler."""
43
+ return
44
+
45
+
46
+ # Register commands — keep the binding here, not in the command modules,
47
+ # so `cli.py` is the one place that lists what's exposed.
48
+ app.command("login")(login)
49
+ app.command("logout")(logout)
50
+ app.command("whoami")(whoami)
51
+ app.command("status")(status)
52
+ app.command("projects")(projects)
53
+ app.command("envs")(envs)
54
+ app.command("vars")(list_vars)
55
+ # `run` needs `allow_extra_args` so Typer hands us argv after `--`.
56
+ app.command(
57
+ "run",
58
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
59
+ )(run)
60
+
61
+
62
+ def _entrypoint() -> None:
63
+ """Wraps the Typer app to map KlavexError -> typed exit code + clean message."""
64
+ try:
65
+ app(standalone_mode=False)
66
+ except KlavexError as exc:
67
+ typer.secho(exc.user_message(), err=True, fg=typer.colors.RED)
68
+ if _wants_debug():
69
+ traceback.print_exc()
70
+ sys.exit(exc.exit_code)
71
+ except KeyboardInterrupt:
72
+ typer.echo("", err=True)
73
+ sys.exit(130)
74
+ except typer.Exit as exc:
75
+ sys.exit(exc.exit_code)
76
+ except SystemExit:
77
+ raise
78
+ except Exception:
79
+ typer.secho("Unexpected error.", err=True, fg=typer.colors.RED)
80
+ traceback.print_exc()
81
+ sys.exit(1)
82
+
83
+
84
+ def _wants_debug() -> bool:
85
+ # Read --debug flag without going through Typer's context, since the exception
86
+ # may have escaped before the context was populated.
87
+ return "--debug" in sys.argv
88
+
89
+
90
+ # Console-script entry: pyproject.toml points `klavex` and `kx` here.
91
+ def main() -> None:
92
+ _entrypoint()
93
+
94
+
95
+ # Backwards-compat: `python -m klavex` (and the pyproject entry-points that
96
+ # reference `klavex.cli:app`) both work.
97
+ if __name__ == "__main__":
98
+ main()
File without changes
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from .. import tokens
6
+ from ..api import ApiClient
7
+ from ..config import UserConfig
8
+ from ..errors import NotAuthenticated
9
+
10
+
11
+ def envs(project_id: str = typer.Argument(..., help="Project ID, e.g. proj_abc123")) -> None:
12
+ """List environments in a project."""
13
+ token = tokens.load()
14
+ if not token:
15
+ raise NotAuthenticated()
16
+
17
+ cfg = UserConfig.load()
18
+ with ApiClient(cfg.api_url, token=token) as api:
19
+ rows = api.list_environments(project_id)
20
+
21
+ if not rows:
22
+ typer.echo("No environments found.")
23
+ return
24
+
25
+ width_id = max((len(r.get("id", "")) for r in rows), default=4)
26
+ width_name = max((len(r.get("name", "")) for r in rows), default=4)
27
+ typer.echo(f"{'ID'.ljust(width_id)} {'NAME'.ljust(width_name)}")
28
+ for r in rows:
29
+ typer.echo(f"{r.get('id', '').ljust(width_id)} {r.get('name', '').ljust(width_name)}")
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from .. import tokens
6
+ from ..api import ApiClient
7
+ from ..auth import browser_login, device_login
8
+ from ..config import UserConfig
9
+
10
+
11
+ def login(
12
+ device: bool = typer.Option(False, "--device", help="Use device-code flow (no browser open)."),
13
+ no_browser: bool = typer.Option(
14
+ False, "--no-browser", help="Print the URL instead of opening a browser."
15
+ ),
16
+ timeout: int = typer.Option(120, "--timeout", help="Seconds to wait for the browser callback."),
17
+ ) -> None:
18
+ """Log in to Klavex. Opens your browser for authentication."""
19
+ cfg = UserConfig.load()
20
+
21
+ with ApiClient(cfg.api_url) as api:
22
+ if device:
23
+ response = device_login(api, open_browser=not no_browser)
24
+ else:
25
+ response = browser_login(
26
+ api,
27
+ dashboard_url=cfg.dashboard_url,
28
+ timeout_seconds=timeout,
29
+ open_browser=not no_browser,
30
+ )
31
+
32
+ tokens.store(response.cli_token)
33
+
34
+ user_name = response.user.get("name") or response.user.get("email") or "you"
35
+ typer.echo(f"Logged in as {user_name}.")
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from .. import tokens
6
+ from ..api import ApiClient
7
+ from ..config import UserConfig
8
+ from ..errors import NetworkError, NotAuthenticated
9
+
10
+
11
+ def logout(
12
+ keep_remote: bool = typer.Option(
13
+ False,
14
+ "--keep-remote",
15
+ help="Only delete the local token, don't revoke server-side.",
16
+ ),
17
+ ) -> None:
18
+ """Log out of Klavex. Deletes the local token and revokes it server-side."""
19
+ token = tokens.load()
20
+ if not token:
21
+ typer.echo("Already logged out.")
22
+ return
23
+
24
+ if not keep_remote:
25
+ cfg = UserConfig.load()
26
+ try:
27
+ with ApiClient(cfg.api_url, token=token) as api:
28
+ api.revoke_current_token()
29
+ except (NetworkError, NotAuthenticated):
30
+ # If the server is unreachable or the token is already invalid, the
31
+ # local delete still matters. Don't block the user on it.
32
+ pass
33
+
34
+ tokens.delete()
35
+ typer.echo("Logged out.")
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from .. import tokens
6
+ from ..api import ApiClient
7
+ from ..config import UserConfig
8
+ from ..errors import NotAuthenticated
9
+
10
+
11
+ def projects() -> None:
12
+ """List projects in your team."""
13
+ token = tokens.load()
14
+ if not token:
15
+ raise NotAuthenticated()
16
+
17
+ cfg = UserConfig.load()
18
+ with ApiClient(cfg.api_url, token=token) as api:
19
+ rows = api.list_projects()
20
+
21
+ if not rows:
22
+ typer.echo("No projects found.")
23
+ return
24
+
25
+ width_id = max((len(r.get("id", "")) for r in rows), default=4)
26
+ width_name = max((len(r.get("name", "")) for r in rows), default=4)
27
+ typer.echo(f"{'ID'.ljust(width_id)} {'NAME'.ljust(width_name)}")
28
+ for r in rows:
29
+ typer.echo(f"{r.get('id', '').ljust(width_id)} {r.get('name', '').ljust(width_name)}")
klavex/commands/run.py ADDED
@@ -0,0 +1,65 @@
1
+ """klavex run -e <env_id> -- <cmd...>
2
+
3
+ Pulls variables for the env, spawns <cmd> with them in its environment.
4
+ The parent shell never sees the secrets; nothing is written to disk.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+
11
+ from .. import tokens
12
+ from ..api import ApiClient
13
+ from ..config import ProjectPin, UserConfig
14
+ from ..errors import NotAuthenticated, UsageError
15
+ from ..inject import run as inject_run
16
+
17
+
18
+ def run(
19
+ ctx: typer.Context,
20
+ env: str | None = typer.Option(
21
+ None,
22
+ "-e",
23
+ "--env",
24
+ help="Environment ID. Falls back to default_env in .klavex.",
25
+ ),
26
+ no_inherit: bool = typer.Option(
27
+ False,
28
+ "--no-inherit",
29
+ help="Don't inherit the parent shell's env. Only the fetched vars are passed in.",
30
+ ),
31
+ ) -> None:
32
+ """Run a command with vars from an environment injected into its process env.
33
+
34
+ Example: klavex run -e env_dev_abc -- npm start
35
+ """
36
+ token = tokens.load()
37
+ if not token:
38
+ raise NotAuthenticated()
39
+
40
+ argv = list(ctx.args)
41
+ if not argv:
42
+ raise UsageError("Nothing to run. Pass a command after `--`.")
43
+
44
+ env_id = env
45
+ if not env_id:
46
+ pin = ProjectPin.find()
47
+ if pin and pin.default_env:
48
+ env_id = pin.default_env
49
+ if not env_id:
50
+ raise UsageError(
51
+ "No environment specified. Pass -e <env_id> or set default_env in a .klavex file."
52
+ )
53
+
54
+ cfg = UserConfig.load()
55
+ with ApiClient(cfg.api_url, token=token) as api:
56
+ rows = api.list_variables(env_id)
57
+
58
+ env_vars: dict[str, str] = {}
59
+ for r in rows:
60
+ key = r.get("key")
61
+ value = r.get("value")
62
+ if isinstance(key, str) and isinstance(value, str):
63
+ env_vars[key] = value
64
+
65
+ raise typer.Exit(code=inject_run(env_vars, argv, inherit_parent=not no_inherit))
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from .. import tokens
6
+ from ..config import ProjectPin, UserConfig
7
+
8
+
9
+ def status() -> None:
10
+ """Show local CLI state — login, project pin, API URL — without hitting the network."""
11
+ cfg = UserConfig.load()
12
+ has_token = tokens.load() is not None
13
+
14
+ typer.echo(f"API URL: {cfg.api_url}")
15
+ typer.echo(f"Dashboard URL: {cfg.dashboard_url}")
16
+ typer.echo(f"Logged in: {'yes' if has_token else 'no'}")
17
+
18
+ pin = ProjectPin.find()
19
+ if pin:
20
+ typer.echo(f"Project pin: {pin.project} (from {pin.path})")
21
+ if pin.default_env:
22
+ typer.echo(f"Default env: {pin.default_env}")
23
+ else:
24
+ typer.echo("Project pin: none (no .klavex file in cwd or parents)")
@@ -0,0 +1,36 @@
1
+ """List variable NAMES in an environment.
2
+
3
+ Values are never printed by this command — that's what `run` is for.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import typer
9
+
10
+ from .. import tokens
11
+ from ..api import ApiClient
12
+ from ..config import UserConfig
13
+ from ..errors import NotAuthenticated
14
+
15
+
16
+ def list_vars(
17
+ env: str = typer.Option(..., "-e", "--env", help="Environment ID"),
18
+ ) -> None:
19
+ """List variable names in an environment (values are never shown)."""
20
+ token = tokens.load()
21
+ if not token:
22
+ raise NotAuthenticated()
23
+
24
+ cfg = UserConfig.load()
25
+ with ApiClient(cfg.api_url, token=token) as api:
26
+ rows = api.list_variables(env)
27
+
28
+ if not rows:
29
+ typer.echo("No variables in this environment.")
30
+ return
31
+
32
+ keys = sorted(r.get("key", "") for r in rows)
33
+ is_secret_by_key = {r.get("key", ""): r.get("is_secret", False) for r in rows}
34
+ for k in keys:
35
+ marker = " (secret)" if is_secret_by_key.get(k) else ""
36
+ typer.echo(f"{k}{marker}")
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from .. import tokens
6
+ from ..api import ApiClient
7
+ from ..config import UserConfig
8
+ from ..errors import NotAuthenticated
9
+
10
+
11
+ def whoami() -> None:
12
+ """Show the currently logged-in user and team."""
13
+ token = tokens.load()
14
+ if not token:
15
+ raise NotAuthenticated()
16
+
17
+ cfg = UserConfig.load()
18
+ with ApiClient(cfg.api_url, token=token) as api:
19
+ me = api.me()
20
+
21
+ user = me.get("user", me) if isinstance(me, dict) else {}
22
+ name = user.get("name") or user.get("email") or "?"
23
+ email = user.get("email", "?")
24
+ role = me.get("role") if isinstance(me, dict) else None
25
+
26
+ typer.echo(f"User: {name} <{email}>")
27
+ if role:
28
+ typer.echo(f"Role: {role}")
klavex/config.py ADDED
@@ -0,0 +1,109 @@
1
+ """Non-secret configuration.
2
+
3
+ Two scopes:
4
+ - User config: ~/.config/klavex/config.toml — API URL override, telemetry opt-out.
5
+ - Project pin: .klavex — committed to the project; pins project_id + default_env.
6
+
7
+ Tokens never live here. Tokens go in the OS keychain (see tokens.py).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ import tomli_w
18
+ from platformdirs import user_config_path
19
+
20
+ if sys.version_info >= (3, 11):
21
+ import tomllib
22
+ else:
23
+ import tomli as tomllib # type: ignore[import-not-found]
24
+
25
+ DEFAULT_API_URL = "https://api.klavex.dev/api"
26
+ DEFAULT_DASHBOARD_URL = "https://app.klavex.dev"
27
+
28
+ PIN_FILENAME = ".klavex"
29
+
30
+
31
+ def _user_config_path() -> Path:
32
+ return user_config_path("klavex") / "config.toml"
33
+
34
+
35
+ @dataclass
36
+ class UserConfig:
37
+ api_url: str = DEFAULT_API_URL
38
+ dashboard_url: str = DEFAULT_DASHBOARD_URL
39
+ telemetry: bool = True
40
+
41
+ @classmethod
42
+ def load(cls) -> UserConfig:
43
+ path = _user_config_path()
44
+ data: dict[str, object] = {}
45
+ if path.exists():
46
+ with path.open("rb") as f:
47
+ data = tomllib.load(f)
48
+
49
+ # Env overrides win over file config.
50
+ api_url = os.environ.get("KLAVEX_API_URL") or data.get("api_url") or DEFAULT_API_URL
51
+ dashboard_url = (
52
+ os.environ.get("KLAVEX_DASHBOARD_URL")
53
+ or data.get("dashboard_url")
54
+ or DEFAULT_DASHBOARD_URL
55
+ )
56
+ telemetry = bool(data.get("telemetry", True))
57
+ if os.environ.get("KLAVEX_NO_TELEMETRY"):
58
+ telemetry = False
59
+ return cls(api_url=str(api_url), dashboard_url=str(dashboard_url), telemetry=telemetry)
60
+
61
+ def save(self) -> None:
62
+ path = _user_config_path()
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ with path.open("wb") as f:
65
+ tomli_w.dump(
66
+ {
67
+ "api_url": self.api_url,
68
+ "dashboard_url": self.dashboard_url,
69
+ "telemetry": self.telemetry,
70
+ },
71
+ f,
72
+ )
73
+
74
+
75
+ @dataclass
76
+ class ProjectPin:
77
+ project: str
78
+ default_env: str | None = None
79
+ path: Path | None = None # the .klavex file we read it from
80
+
81
+ @classmethod
82
+ def find(cls, start: Path | None = None) -> ProjectPin | None:
83
+ """Walk up from `start` (default cwd) looking for a .klavex file."""
84
+ cur = (start or Path.cwd()).resolve()
85
+ for d in (cur, *cur.parents):
86
+ candidate = d / PIN_FILENAME
87
+ if candidate.is_file():
88
+ with candidate.open("rb") as f:
89
+ data = tomllib.load(f)
90
+ project = data.get("project")
91
+ if not isinstance(project, str):
92
+ return None
93
+ default_env = data.get("default_env")
94
+ return cls(
95
+ project=project,
96
+ default_env=str(default_env) if isinstance(default_env, str) else None,
97
+ path=candidate,
98
+ )
99
+ return None
100
+
101
+ @classmethod
102
+ def write(cls, project: str, default_env: str | None, dest: Path | None = None) -> Path:
103
+ path = (dest or Path.cwd()) / PIN_FILENAME
104
+ payload: dict[str, object] = {"project": project}
105
+ if default_env:
106
+ payload["default_env"] = default_env
107
+ with path.open("wb") as f:
108
+ tomli_w.dump(payload, f)
109
+ return path
klavex/errors.py ADDED
@@ -0,0 +1,61 @@
1
+ """Typed exceptions mapped to deterministic CLI exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class KlavexError(Exception):
7
+ """Base error. Each subclass owns one exit code."""
8
+
9
+ exit_code: int = 1
10
+
11
+ def user_message(self) -> str:
12
+ return str(self)
13
+
14
+
15
+ class UsageError(KlavexError):
16
+ exit_code = 2
17
+
18
+
19
+ class NotAuthenticated(KlavexError):
20
+ exit_code = 3
21
+
22
+ def user_message(self) -> str:
23
+ return "Not logged in. Run `klavex login` first."
24
+
25
+
26
+ class NetworkError(KlavexError):
27
+ exit_code = 4
28
+
29
+
30
+ class PermissionDenied(KlavexError):
31
+ exit_code = 5
32
+
33
+
34
+ class NotFound(KlavexError):
35
+ exit_code = 6
36
+
37
+
38
+ class AuthFlowError(KlavexError):
39
+ """Anything that goes wrong during the browser/device-code flow."""
40
+
41
+ exit_code = 3
42
+
43
+
44
+ class StateMismatch(AuthFlowError):
45
+ def user_message(self) -> str:
46
+ return "Login aborted: state parameter did not match. Try again."
47
+
48
+
49
+ class AuthTimeout(AuthFlowError):
50
+ def __init__(self, seconds: int) -> None:
51
+ super().__init__(f"Login timed out after {seconds} seconds")
52
+
53
+
54
+ class TokenStoreUnavailable(KlavexError):
55
+ exit_code = 1
56
+
57
+ def user_message(self) -> str:
58
+ return (
59
+ "No OS keychain backend is available, so the CLI token cannot be stored "
60
+ "securely. Install a keyring backend (e.g. `secretstorage` on Linux) and retry."
61
+ )
klavex/inject.py ADDED
@@ -0,0 +1,28 @@
1
+ """The headline mechanism: spawn a child process with secrets in its env,
2
+ and never write them to disk.
3
+
4
+ This is twelve lines and the entire reason the product exists.
5
+
6
+ Hard rules:
7
+ - NEVER use shell=True (turns unescaped values into shell injection).
8
+ - NEVER write env values to a log, telemetry payload, or temp file.
9
+ - NEVER mutate os.environ in the parent — secrets only exist in the child.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import subprocess
16
+
17
+ from .errors import UsageError
18
+
19
+
20
+ def run(env_vars: dict[str, str], argv: list[str], inherit_parent: bool = True) -> int:
21
+ """Run argv with `env_vars` injected into its environment. Returns exit code."""
22
+ if not argv:
23
+ raise UsageError("Nothing to run. Pass a command after `--`.")
24
+
25
+ base = dict(os.environ) if inherit_parent else {}
26
+ child_env = {**base, **env_vars}
27
+ proc = subprocess.run(argv, env=child_env)
28
+ return proc.returncode
klavex/tokens.py ADDED
@@ -0,0 +1,42 @@
1
+ """CLI token storage in the OS keychain.
2
+
3
+ We refuse to fall back to a plaintext file. The whole product premise is
4
+ "no plaintext secrets on disk", and that has to apply to our own auth too.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import keyring
10
+ import keyring.errors
11
+ from keyring.backends.fail import Keyring as FailKeyring
12
+
13
+ from .errors import TokenStoreUnavailable
14
+
15
+ SERVICE = "klavex"
16
+ DEFAULT_PROFILE = "default"
17
+
18
+
19
+ def _ensure_backend() -> None:
20
+ if isinstance(keyring.get_keyring(), FailKeyring):
21
+ raise TokenStoreUnavailable()
22
+
23
+
24
+ def store(token: str, profile: str = DEFAULT_PROFILE) -> None:
25
+ _ensure_backend()
26
+ keyring.set_password(SERVICE, profile, token)
27
+
28
+
29
+ def load(profile: str = DEFAULT_PROFILE) -> str | None:
30
+ try:
31
+ return keyring.get_password(SERVICE, profile)
32
+ except keyring.errors.KeyringError:
33
+ return None
34
+
35
+
36
+ def delete(profile: str = DEFAULT_PROFILE) -> bool:
37
+ """Returns True if a token was removed, False if there was nothing to remove."""
38
+ try:
39
+ keyring.delete_password(SERVICE, profile)
40
+ return True
41
+ except keyring.errors.PasswordDeleteError:
42
+ return False
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: klavex
3
+ Version: 0.1.0
4
+ Summary: Klavex CLI — pull environment variables into a process without writing secrets to disk.
5
+ Author: Klavex
6
+ License: Proprietary
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: MacOS
11
+ Classifier: Operating System :: Microsoft :: Windows
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: httpx<1.0,>=0.27
19
+ Requires-Dist: keyring<26,>=24
20
+ Requires-Dist: platformdirs<5,>=4
21
+ Requires-Dist: tomli-w<2.0,>=1.0
22
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
23
+ Requires-Dist: typer<1.0,>=0.12
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy>=1.11; extra == 'dev'
26
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
27
+ Requires-Dist: pytest>=8; extra == 'dev'
28
+ Requires-Dist: respx>=0.21; extra == 'dev'
29
+ Requires-Dist: ruff>=0.6; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # klavex
33
+
34
+ CLI for [Klavex](https://klavex.dev). Pulls environment variables from your team's vault and injects them into a child process — secrets never touch disk.
35
+
36
+ ```bash
37
+ pip install klavex
38
+ klavex login
39
+ klavex run -- npm start
40
+ ```
41
+
42
+ See [IMPLEMENTATION.md](./IMPLEMENTATION.md) for the design.
43
+
44
+ ## Commands
45
+
46
+ | Command | Status |
47
+ | --- | --- |
48
+ | `klavex login` | v0.1 |
49
+ | `klavex logout` | v0.1 |
50
+ | `klavex whoami` | v0.1 |
51
+ | `klavex status` | v0.1 |
52
+ | `klavex projects` | v0.2 |
53
+ | `klavex envs <project>` | v0.2 |
54
+ | `klavex vars -e <env>` | v0.2 |
55
+ | `klavex run -e <env> -- <cmd>` | v0.3 (blocked on backend reveal endpoint) |
56
+ | `klavex export -e <env>` | v0.4 |
57
+ | `klavex use -p <project> -e <env>` | v0.4 |
58
+
59
+ ## Development
60
+
61
+ ```bash
62
+ pip install -e ".[dev]"
63
+ klavex --version
64
+ pytest
65
+ ruff check .
66
+ mypy
67
+ ```
68
+
69
+ Override the API URL for staging or local:
70
+
71
+ ```bash
72
+ export KLAVEX_API_URL=http://localhost:8000
73
+ ```
@@ -0,0 +1,22 @@
1
+ klavex/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ klavex/__main__.py,sha256=cXCL1fiQxRjqFbb1L5R1cmFa8T95gAuJBMFLEOFGnUw,65
3
+ klavex/api.py,sha256=TKGeGlzREaIuhwIP-Is8rmlDIStRX6PX8O5kbuKAhTA,5516
4
+ klavex/auth.py,sha256=5y77Lc4qimvEFm4Vje_XtT9m8Yq_rjYdayFzqNp0mho,6746
5
+ klavex/cli.py,sha256=DMTygeIWDEKd2hVHH92Zo5u7GG6WIQPP2MXjfIXbPQ8,2849
6
+ klavex/config.py,sha256=1YFHy6PVfo-rOVYafp_YRov-XLp6aejG-WDAUtIc578,3456
7
+ klavex/errors.py,sha256=BDt5v-AYkPBnNOwtixXCDd8Wyoho1P_FNIF8k2iegFI,1362
8
+ klavex/inject.py,sha256=6ApAbsccvsPRz_3vRtP5OBS8-gOydvyyB3GIVqvv5Bk,932
9
+ klavex/tokens.py,sha256=xz2SbZtLQIlsG5V0_vCbpmItINq3p_lT2e9T4pvdTKM,1146
10
+ klavex/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ klavex/commands/envs.py,sha256=UyI4VQ09NxJlRvmtuMrJCsH2NxQgUiGQIM2d7jXQgwA,920
12
+ klavex/commands/login.py,sha256=pjJ8gklfS71UKRh3v3fi9X1msc8zCFgN3_xNY5TnK08,1142
13
+ klavex/commands/logout.py,sha256=1Q0ekSzsgsLUfAsQ0DqnBOrHtlMZ8aQfhfyHMhfYFvo,988
14
+ klavex/commands/projects.py,sha256=km6MNEYqfXvoHjiHBQ3WL7Nn-4nqKFbDTdZdYiPwGsc,828
15
+ klavex/commands/run.py,sha256=Cc26CD2O9NyYi6LUbv7qNOcgn0qF_guDPPwpuxma2LE,1840
16
+ klavex/commands/status.py,sha256=XoaiB1gjg8217USQqvHos6wdI8KxPsG3Ep3h2PBB1iI,767
17
+ klavex/commands/vars.py,sha256=1SOOpjdhpYLGb78j4O9u-JcO3wkiz4OGMgtVR0A4oh4,1002
18
+ klavex/commands/whoami.py,sha256=9dRB24d8xhFDO2adkwl_69qjg-qpUCJzcIfjL7zdwss,737
19
+ klavex-0.1.0.dist-info/METADATA,sha256=So-mgIigU7R8y6mbgNMiEcdMVpy7fNpzY47KbAMYQ3Y,2066
20
+ klavex-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
21
+ klavex-0.1.0.dist-info/entry_points.txt,sha256=9kKuhD7EfWiHS8NF_RPMfvUBCbUZIOISXe7oGNblUY4,64
22
+ klavex-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ klavex = klavex.cli:main
3
+ kx = klavex.cli:main