kater-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.
Files changed (42) hide show
  1. kater_cli/.gitkeep +0 -0
  2. kater_cli/auth/__init__.py +6 -0
  3. kater_cli/auth/oauth.py +211 -0
  4. kater_cli/auth/token_manager.py +338 -0
  5. kater_cli/client.py +82 -0
  6. kater_cli/commands/.gitkeep +0 -0
  7. kater_cli/commands/__init__.py +6 -0
  8. kater_cli/commands/_shared.py +281 -0
  9. kater_cli/commands/auth.py +78 -0
  10. kater_cli/commands/compile.py +379 -0
  11. kater_cli/commands/config/__init__.py +10 -0
  12. kater_cli/commands/config/app.py +40 -0
  13. kater_cli/commands/config/default_connection.py +202 -0
  14. kater_cli/commands/dev.py +382 -0
  15. kater_cli/commands/import_/__init__.py +6 -0
  16. kater_cli/commands/import_/app.py +16 -0
  17. kater_cli/commands/import_/tenants.py +520 -0
  18. kater_cli/commands/install.py +212 -0
  19. kater_cli/commands/run.py +335 -0
  20. kater_cli/commands/scaffold/__init__.py +0 -0
  21. kater_cli/commands/scaffold/app.py +17 -0
  22. kater_cli/commands/scaffold/topic.py +85 -0
  23. kater_cli/commands/validate.py +381 -0
  24. kater_cli/config.py +39 -0
  25. kater_cli/main.py +48 -0
  26. kater_cli/preferences/__init__.py +5 -0
  27. kater_cli/preferences/preference_manager.py +75 -0
  28. kater_cli/prompts.py +89 -0
  29. kater_cli/repo.py +101 -0
  30. kater_cli/services/__init__.py +12 -0
  31. kater_cli/services/dev_file_watcher.py +232 -0
  32. kater_cli/services/file_sync.py +238 -0
  33. kater_cli/services/ws_dev_client.py +410 -0
  34. kater_cli-0.1.0.dist-info/METADATA +14 -0
  35. kater_cli-0.1.0.dist-info/RECORD +42 -0
  36. kater_cli-0.1.0.dist-info/WHEEL +4 -0
  37. kater_cli-0.1.0.dist-info/entry_points.txt +2 -0
  38. kater_utils/__init__.py +37 -0
  39. kater_utils/file_utils.py +72 -0
  40. kater_utils/logging.py +159 -0
  41. kater_utils/repo.py +77 -0
  42. kater_utils/scaffold_templates.py +398 -0
kater_cli/.gitkeep ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ """Authentication module for the Kater CLI."""
2
+
3
+ from kater_cli.auth.oauth import run_oauth_flow
4
+ from kater_cli.auth.token_manager import TokenManager
5
+
6
+ __all__ = ["TokenManager", "run_oauth_flow"]
@@ -0,0 +1,211 @@
1
+ """OAuth 2.0 Authorization Code flow with PKCE for CLI authentication."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import secrets
6
+ import webbrowser
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+ from threading import Thread
9
+ from urllib.parse import parse_qs, urlencode, urlparse
10
+
11
+ import httpx
12
+ from rich.console import Console
13
+
14
+ from kater_cli.auth.token_manager import TokenManager
15
+ from kater_cli.config import get_settings
16
+
17
+ console = Console()
18
+
19
+
20
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
21
+ """HTTP handler for OAuth callback."""
22
+
23
+ authorization_code: str | None = None
24
+ state: str | None = None
25
+ error: str | None = None
26
+
27
+ def log_message(self, format: str, *args: object) -> None:
28
+ """Suppress server logs."""
29
+ pass
30
+
31
+ def do_GET(self) -> None:
32
+ """Handle GET request from OAuth callback."""
33
+ parsed = urlparse(self.path)
34
+ if parsed.path != "/callback":
35
+ self.send_response(404)
36
+ self.end_headers()
37
+ return
38
+
39
+ params = parse_qs(parsed.query)
40
+
41
+ if "error" in params:
42
+ OAuthCallbackHandler.error = params["error"][0]
43
+ self._send_error_response(params.get("error_description", ["Unknown error"])[0])
44
+ return
45
+
46
+ if "code" not in params or "state" not in params:
47
+ OAuthCallbackHandler.error = "missing_params"
48
+ self._send_error_response("Missing authorization code or state")
49
+ return
50
+
51
+ OAuthCallbackHandler.authorization_code = params["code"][0]
52
+ OAuthCallbackHandler.state = params["state"][0]
53
+ self._send_success_response()
54
+
55
+ def _send_success_response(self) -> None:
56
+ """Send success HTML response."""
57
+ self.send_response(200)
58
+ self.send_header("Content-type", "text/html")
59
+ self.end_headers()
60
+ html = """
61
+ <!DOCTYPE html>
62
+ <html>
63
+ <head><title>Login Successful</title></head>
64
+ <body style="font-family: system-ui; text-align: center; padding: 50px;">
65
+ <h1>Login Successful!</h1>
66
+ <p>You can close this window and return to the terminal.</p>
67
+ </body>
68
+ </html>
69
+ """
70
+ self.wfile.write(html.encode())
71
+
72
+ def _send_error_response(self, message: str) -> None:
73
+ """Send error HTML response."""
74
+ self.send_response(400)
75
+ self.send_header("Content-type", "text/html")
76
+ self.end_headers()
77
+ html = f"""
78
+ <!DOCTYPE html>
79
+ <html>
80
+ <head><title>Login Failed</title></head>
81
+ <body style="font-family: system-ui; text-align: center; padding: 50px;">
82
+ <h1>Login Failed</h1>
83
+ <p>{message}</p>
84
+ <p>Please try again from the terminal.</p>
85
+ </body>
86
+ </html>
87
+ """
88
+ self.wfile.write(html.encode())
89
+
90
+
91
+ def _generate_pkce_pair() -> tuple[str, str]:
92
+ """Generate PKCE code verifier and challenge.
93
+
94
+ Returns:
95
+ Tuple of (code_verifier, code_challenge)
96
+ """
97
+ code_verifier = secrets.token_urlsafe(32)
98
+ code_challenge_bytes = hashlib.sha256(code_verifier.encode()).digest()
99
+ code_challenge = base64.urlsafe_b64encode(code_challenge_bytes).rstrip(b"=").decode()
100
+ return code_verifier, code_challenge
101
+
102
+
103
+ def _generate_state() -> str:
104
+ """Generate random state parameter for CSRF protection."""
105
+ return secrets.token_urlsafe(16)
106
+
107
+
108
+ def run_oauth_flow() -> bool:
109
+ """Run the OAuth 2.0 authorization code flow with PKCE.
110
+
111
+ Opens browser for user authentication and captures the callback.
112
+
113
+ Returns:
114
+ True if login successful, False otherwise.
115
+ """
116
+ settings = get_settings()
117
+
118
+ if not settings.oauth_client_id:
119
+ console.print("[red]Error: KATER_OAUTH_CLIENT_ID environment variable is not set.[/red]")
120
+ console.print("Please set it to your PropelAuth OAuth client ID.")
121
+ return False
122
+
123
+ # Reset handler state
124
+ OAuthCallbackHandler.authorization_code = None
125
+ OAuthCallbackHandler.state = None
126
+ OAuthCallbackHandler.error = None
127
+
128
+ # Generate PKCE pair and state
129
+ code_verifier, code_challenge = _generate_pkce_pair()
130
+ state = _generate_state()
131
+
132
+ # Build authorization URL
133
+ redirect_uri = f"http://localhost:{settings.oauth_callback_port}/callback"
134
+ auth_params = {
135
+ "client_id": settings.oauth_client_id,
136
+ "response_type": "code",
137
+ "redirect_uri": redirect_uri,
138
+ "scope": "openid email profile",
139
+ "state": state,
140
+ "code_challenge": code_challenge,
141
+ "code_challenge_method": "S256",
142
+ }
143
+ auth_url = f"{settings.oauth_auth_url}?{urlencode(auth_params)}"
144
+
145
+ # Start local callback server
146
+ server = HTTPServer(("localhost", settings.oauth_callback_port), OAuthCallbackHandler)
147
+
148
+ def handle_request() -> None:
149
+ server.handle_request()
150
+
151
+ server_thread = Thread(target=handle_request)
152
+ server_thread.start()
153
+
154
+ # Open browser for authentication
155
+ console.print("Opening browser for authentication...")
156
+ console.print(f"[dim]If browser doesn't open, visit: {auth_url}[/dim]")
157
+ webbrowser.open(auth_url)
158
+
159
+ # Wait for callback
160
+ console.print("Waiting for authentication...")
161
+ server_thread.join(timeout=120)
162
+ server.server_close()
163
+
164
+ # Check for errors
165
+ if OAuthCallbackHandler.error:
166
+ console.print(f"[red]Authentication failed: {OAuthCallbackHandler.error}[/red]")
167
+ return False
168
+
169
+ if not OAuthCallbackHandler.authorization_code:
170
+ console.print("[red]Authentication timed out. Please try again.[/red]")
171
+ return False
172
+
173
+ received_state = OAuthCallbackHandler.state
174
+ if received_state is None or received_state != state:
175
+ console.print("[red]Invalid state parameter. Possible CSRF attack.[/red]")
176
+ return False
177
+
178
+ # Exchange code for tokens
179
+ try:
180
+ response = httpx.post(
181
+ settings.oauth_token_url,
182
+ data={
183
+ "grant_type": "authorization_code",
184
+ "code": OAuthCallbackHandler.authorization_code,
185
+ "redirect_uri": redirect_uri,
186
+ "client_id": settings.oauth_client_id,
187
+ "code_verifier": code_verifier,
188
+ },
189
+ timeout=30.0,
190
+ )
191
+
192
+ if response.status_code != 200:
193
+ console.print(f"[red]Token exchange failed: {response.text}[/red]")
194
+ return False
195
+
196
+ data = response.json()
197
+
198
+ # Store tokens
199
+ token_manager = TokenManager()
200
+ token_manager.store_tokens(
201
+ access_token=data["access_token"],
202
+ refresh_token=data["refresh_token"],
203
+ expires_in=data["expires_in"],
204
+ )
205
+
206
+ console.print("[green]Successfully logged in![/green]")
207
+ return True
208
+
209
+ except httpx.RequestError as e:
210
+ console.print(f"[red]Network error during token exchange: {e}[/red]")
211
+ return False
@@ -0,0 +1,338 @@
1
+ """Token management using system keyring for secure storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import secrets
8
+ import time
9
+ from collections.abc import Callable, Generator
10
+ from dataclasses import dataclass
11
+
12
+ import httpx
13
+ import keyring
14
+
15
+ from kater_cli.config import get_settings
16
+
17
+
18
+ class _TokenRefreshRejected(Exception):
19
+ """Auth server definitively rejected the refresh token (401/403)."""
20
+
21
+
22
+ # CLI Identity key for keyring storage
23
+ CLI_ID_KEY = "cli_id"
24
+
25
+
26
+ def get_or_create_cli_id() -> str:
27
+ """Get existing cli_id from keyring or generate a new one.
28
+
29
+ The cli_id is a persistent identifier stored in the system keyring
30
+ that uniquely identifies a developer's machine. It is used as the
31
+ HTTP header X-Kater-CLI-ID to route API requests to the correct
32
+ Virtual Filesystem (VFS).
33
+
34
+ Returns:
35
+ The cli_id string (format: cli_ + 12-char hex)
36
+ """
37
+ settings = get_settings()
38
+
39
+ # Try to get existing cli_id
40
+ cli_id = keyring.get_password(settings.keyring_service, CLI_ID_KEY)
41
+ if cli_id is not None:
42
+ return cli_id
43
+
44
+ # Generate new cli_id: cli_ prefix + 12 hex characters
45
+ cli_id = f"cli_{secrets.token_hex(6)}"
46
+ keyring.set_password(settings.keyring_service, CLI_ID_KEY, cli_id)
47
+ return cli_id
48
+
49
+
50
+ @dataclass
51
+ class TokenData:
52
+ """Stored token data."""
53
+
54
+ access_token: str
55
+ refresh_token: str
56
+ expires_at: float # Unix timestamp
57
+
58
+
59
+ class TokenManager:
60
+ """Manages OAuth tokens with secure keyring storage and automatic refresh."""
61
+
62
+ KEYRING_USERNAME = "oauth_tokens"
63
+ REFRESH_BUFFER_SECONDS = 300 # Refresh 5 minutes before expiration
64
+
65
+ def __init__(self) -> None:
66
+ self._settings = get_settings()
67
+ self._service = self._settings.keyring_service
68
+ self._background_task: asyncio.Task[None] | None = None
69
+ self._cached_tokens: TokenData | None = None
70
+ self._cache_loaded: bool = False
71
+
72
+ def _get_stored_tokens(self) -> TokenData | None:
73
+ """Retrieve tokens, using in-memory cache to avoid repeated keyring prompts."""
74
+ if self._cache_loaded:
75
+ return self._cached_tokens
76
+ data = keyring.get_password(self._service, self.KEYRING_USERNAME)
77
+ if not data:
78
+ self._cached_tokens = None
79
+ self._cache_loaded = True
80
+ return None
81
+ try:
82
+ parsed = json.loads(data)
83
+ self._cached_tokens = TokenData(
84
+ access_token=parsed["access_token"],
85
+ refresh_token=parsed["refresh_token"],
86
+ expires_at=parsed["expires_at"],
87
+ )
88
+ except (json.JSONDecodeError, KeyError):
89
+ self._cached_tokens = None
90
+ self._cache_loaded = True
91
+ return self._cached_tokens
92
+
93
+ def store_tokens(
94
+ self,
95
+ access_token: str,
96
+ refresh_token: str,
97
+ expires_in: int,
98
+ ) -> None:
99
+ """Store tokens in keyring.
100
+
101
+ Args:
102
+ access_token: The OAuth access token
103
+ refresh_token: The OAuth refresh token
104
+ expires_in: Token lifetime in seconds
105
+ """
106
+ expires_at = time.time() + expires_in
107
+ data = json.dumps(
108
+ {
109
+ "access_token": access_token,
110
+ "refresh_token": refresh_token,
111
+ "expires_at": expires_at,
112
+ }
113
+ )
114
+ keyring.set_password(self._service, self.KEYRING_USERNAME, data)
115
+ # Update in-memory cache so subsequent reads skip the keyring
116
+ self._cached_tokens = TokenData(
117
+ access_token=access_token,
118
+ refresh_token=refresh_token,
119
+ expires_at=expires_at,
120
+ )
121
+ self._cache_loaded = True
122
+
123
+ def clear_tokens(self) -> None:
124
+ """Remove tokens from keyring."""
125
+ self._cached_tokens = None
126
+ self._cache_loaded = True
127
+ try:
128
+ keyring.delete_password(self._service, self.KEYRING_USERNAME)
129
+ except Exception:
130
+ # Password may not exist, which is fine
131
+ pass
132
+
133
+ def _is_token_expired(self, token_data: TokenData) -> bool:
134
+ """Check if token is expired or will expire soon."""
135
+ return time.time() >= (token_data.expires_at - self.REFRESH_BUFFER_SECONDS)
136
+
137
+ def _refresh_tokens(self, refresh_token: str) -> TokenData | None:
138
+ """Exchange refresh token for new access token.
139
+
140
+ Returns:
141
+ TokenData on success, None on failure.
142
+
143
+ Raises:
144
+ _TokenRefreshRejected: When the auth server definitively rejects the
145
+ refresh token (401/403), meaning the token is permanently invalid.
146
+ """
147
+ try:
148
+ response = httpx.post(
149
+ self._settings.oauth_token_url,
150
+ data={
151
+ "grant_type": "refresh_token",
152
+ "refresh_token": refresh_token,
153
+ "client_id": self._settings.oauth_client_id,
154
+ },
155
+ timeout=30.0,
156
+ )
157
+
158
+ if response.status_code in (401, 403):
159
+ raise _TokenRefreshRejected
160
+ if response.status_code != 200:
161
+ return None
162
+
163
+ data = response.json()
164
+ self.store_tokens(
165
+ access_token=data["access_token"],
166
+ refresh_token=data.get("refresh_token", refresh_token),
167
+ expires_in=data["expires_in"],
168
+ )
169
+ return self._cached_tokens
170
+ except httpx.RequestError:
171
+ return None
172
+
173
+ def get_access_token(self) -> str | None:
174
+ """Get a valid access token, refreshing if needed.
175
+
176
+ Returns:
177
+ The access token if valid/refreshable, None if not authenticated.
178
+ """
179
+ token_data = self._get_stored_tokens()
180
+ if not token_data:
181
+ return None
182
+
183
+ if self._is_token_expired(token_data):
184
+ try:
185
+ refreshed = self._refresh_tokens(token_data.refresh_token)
186
+ except _TokenRefreshRejected:
187
+ # Refresh token is permanently invalid — clear and require re-login
188
+ self.clear_tokens()
189
+ return None
190
+ if not refreshed:
191
+ # Transient failure (network, 500, etc.) — return the expired
192
+ # token rather than nuking the keyring. The caller or background
193
+ # refresh can retry later.
194
+ return token_data.access_token
195
+ token_data = refreshed
196
+
197
+ return token_data.access_token
198
+
199
+ def has_tokens(self) -> bool:
200
+ """Check if tokens are stored (may be expired)."""
201
+ return self._get_stored_tokens() is not None
202
+
203
+ def get_token_status(self) -> tuple[bool, float | None]:
204
+ """Get authentication status.
205
+
206
+ Returns:
207
+ Tuple of (is_authenticated, expires_at_timestamp or None)
208
+ """
209
+ token_data = self._get_stored_tokens()
210
+ if not token_data:
211
+ return False, None
212
+ return True, token_data.expires_at
213
+
214
+ def get_token_data(self) -> TokenData | None:
215
+ """Get current token data (for auth flow use).
216
+
217
+ Returns:
218
+ TokenData if tokens are stored, None otherwise.
219
+ """
220
+ return self._get_stored_tokens()
221
+
222
+ def refresh_tokens(self, refresh_token: str) -> TokenData | None:
223
+ """Attempt to refresh tokens using the provided refresh token.
224
+
225
+ This is the public interface for token refresh, used by
226
+ TokenRefreshAuth for 401 retry flow.
227
+
228
+ Args:
229
+ refresh_token: The refresh token to exchange for new tokens.
230
+
231
+ Returns:
232
+ TokenData with new tokens if successful, None if refresh failed.
233
+ """
234
+ return self._refresh_tokens(refresh_token)
235
+
236
+ def start_background_refresh(
237
+ self, callback: Callable[[], None] | None = None
238
+ ) -> asyncio.Task[None]:
239
+ """Start background token refresh timer.
240
+
241
+ Proactively refreshes tokens before they expire to ensure
242
+ uninterrupted WebSocket connections.
243
+
244
+ Args:
245
+ callback: Optional callback invoked after successful refresh.
246
+
247
+ Returns:
248
+ The asyncio Task running the refresh loop.
249
+ """
250
+ self._background_task = asyncio.create_task(self._refresh_loop(callback))
251
+ return self._background_task
252
+
253
+ def stop_background_refresh(self) -> None:
254
+ """Stop the background refresh timer."""
255
+ if self._background_task is not None:
256
+ self._background_task.cancel()
257
+ self._background_task = None
258
+
259
+ async def _refresh_loop(self, callback: Callable[[], None] | None = None) -> None:
260
+ """Background loop that checks and refreshes tokens before expiry.
261
+
262
+ Args:
263
+ callback: Optional callback invoked after successful refresh.
264
+ """
265
+ check_interval = 60 # Check every 60 seconds
266
+
267
+ try:
268
+ while True:
269
+ token_data = self._get_stored_tokens()
270
+ if token_data and self._is_token_expired(token_data):
271
+ try:
272
+ # Run the blocking HTTP call in a thread to avoid freezing the event loop
273
+ refreshed = await asyncio.to_thread(
274
+ self._refresh_tokens, token_data.refresh_token
275
+ )
276
+ except _TokenRefreshRejected:
277
+ # Auth server definitively rejected the refresh token — clear and stop
278
+ self.clear_tokens()
279
+ return
280
+ if refreshed and callback:
281
+ callback()
282
+ # None means transient failure; will retry next interval
283
+
284
+ await asyncio.sleep(check_interval)
285
+ except asyncio.CancelledError:
286
+ # Clean shutdown
287
+ pass
288
+
289
+
290
+ class TokenRefreshAuth(httpx.Auth):
291
+ """httpx Auth class that handles automatic token refresh on 401.
292
+
293
+ This auth class:
294
+ 1. Sets the Authorization header with the current access token
295
+ 2. If a 401 response is received, attempts to refresh the token
296
+ 3. If refresh succeeds, retries the request with the new token
297
+ 4. If refresh fails, clears tokens and returns the 401 response
298
+ """
299
+
300
+ def __init__(self, token_manager: TokenManager) -> None:
301
+ """Initialize with a TokenManager instance.
302
+
303
+ Args:
304
+ token_manager: The TokenManager to use for token operations.
305
+ """
306
+ self._token_manager = token_manager
307
+
308
+ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
309
+ """Implement the httpx auth flow with 401 retry.
310
+
311
+ Args:
312
+ request: The outgoing request.
313
+
314
+ Yields:
315
+ The request (possibly retried with new token).
316
+ """
317
+ # Get current token and set auth header
318
+ token = self._token_manager.get_access_token()
319
+ if token:
320
+ request.headers["Authorization"] = f"Bearer {token}"
321
+
322
+ # Send the request
323
+ response = yield request
324
+
325
+ # Handle 401 - attempt refresh and retry
326
+ if response.status_code == 401:
327
+ token_data = self._token_manager.get_token_data()
328
+ if token_data:
329
+ try:
330
+ refreshed = self._token_manager.refresh_tokens(token_data.refresh_token)
331
+ except _TokenRefreshRejected:
332
+ self._token_manager.clear_tokens()
333
+ return
334
+ if refreshed:
335
+ # Update header with new token and retry
336
+ request.headers["Authorization"] = f"Bearer {refreshed.access_token}"
337
+ yield request
338
+ # Transient failure — don't clear tokens
kater_cli/client.py ADDED
@@ -0,0 +1,82 @@
1
+ """Authenticated API client factory for the Kater CLI."""
2
+
3
+ from collections.abc import Callable
4
+ from functools import wraps
5
+ from typing import ParamSpec, TypeVar
6
+
7
+ import typer
8
+ from kater import DefaultHttpxClient, Kater
9
+ from rich.console import Console
10
+
11
+ from kater_cli.auth.token_manager import TokenManager, TokenRefreshAuth
12
+ from kater_cli.config import get_settings
13
+
14
+ console = Console()
15
+
16
+ P = ParamSpec("P")
17
+ R = TypeVar("R")
18
+
19
+
20
+ def get_authenticated_client() -> Kater:
21
+ """Get an authenticated API client.
22
+
23
+ Retrieves a valid access token (refreshing if needed) and creates
24
+ a Kater client configured for the API with automatic token
25
+ refresh on 401 responses.
26
+
27
+ Raises:
28
+ typer.Exit: If not logged in or token refresh failed.
29
+
30
+ Returns:
31
+ Configured Kater client instance.
32
+ """
33
+ token_manager = TokenManager()
34
+
35
+ # Check if we have any tokens before creating the client
36
+ if not token_manager.has_tokens():
37
+ console.print("[red]Not logged in. Run `kater login` to authenticate.[/red]")
38
+ raise typer.Exit(1)
39
+
40
+ settings = get_settings()
41
+
42
+ # Get the current access token for SDK header validation.
43
+ # TokenRefreshAuth also sets the header at the httpx layer and
44
+ # automatically refreshes the token on 401 responses.
45
+ access_token = token_manager.get_access_token()
46
+ if not access_token:
47
+ console.print("[red]Session expired. Run `kater login` to re-authenticate.[/red]")
48
+ raise typer.Exit(1)
49
+
50
+ return Kater(
51
+ base_url=settings.api_url,
52
+ auth_token=access_token,
53
+ http_client=DefaultHttpxClient(auth=TokenRefreshAuth(token_manager)),
54
+ )
55
+
56
+
57
+ def require_auth(func: Callable[P, R]) -> Callable[P, R]:
58
+ """Decorator for commands that require authentication.
59
+
60
+ Checks for a valid token before running the command. If the token
61
+ is missing or invalid, displays an error and exits.
62
+
63
+ Usage:
64
+ @app.command()
65
+ @require_auth
66
+ def my_command():
67
+ client = get_authenticated_client()
68
+ ...
69
+ """
70
+
71
+ @wraps(func)
72
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
73
+ token_manager = TokenManager()
74
+ token = token_manager.get_access_token()
75
+
76
+ if not token:
77
+ console.print("[red]Not logged in. Run `kater login` to authenticate.[/red]")
78
+ raise typer.Exit(1)
79
+
80
+ return func(*args, **kwargs)
81
+
82
+ return wrapper
File without changes
@@ -0,0 +1,6 @@
1
+ """CLI commands for Kater."""
2
+
3
+ from kater_cli.commands import dev
4
+ from kater_cli.commands.auth import login, logout, status
5
+
6
+ __all__ = ["dev", "login", "logout", "status"]