kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.1.dist-info/METADATA +201 -0
  159. kstlib-1.0.1.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.1.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,74 @@
1
+ """Logout from an OAuth2/OIDC provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from kstlib.cli.common import CommandResult, CommandStatus, console, render_result
8
+
9
+ from .common import PROVIDER_ARGUMENT, QUIET_OPTION, get_provider, resolve_provider_name
10
+
11
+
12
+ def logout(
13
+ provider: str | None = PROVIDER_ARGUMENT,
14
+ quiet: bool = QUIET_OPTION,
15
+ revoke: bool = typer.Option(
16
+ True,
17
+ "--revoke/--no-revoke",
18
+ help="Attempt to revoke token at the authorization server.",
19
+ ),
20
+ ) -> None:
21
+ """Logout from an OAuth2/OIDC provider.
22
+
23
+ Clears the stored token and optionally revokes it at the server.
24
+ """
25
+ provider_name = resolve_provider_name(provider)
26
+ auth_provider = get_provider(provider_name)
27
+
28
+ # Check if authenticated
29
+ token = auth_provider.get_token(auto_refresh=False)
30
+ if token is None:
31
+ if quiet:
32
+ console.print(f"[yellow]{provider_name}: not authenticated[/]")
33
+ else:
34
+ render_result(
35
+ CommandResult(
36
+ status=CommandStatus.WARNING,
37
+ message=f"Not authenticated with {provider_name}.",
38
+ )
39
+ )
40
+ return
41
+
42
+ # Attempt revocation if requested
43
+ revoked = False
44
+ if revoke:
45
+ if not quiet:
46
+ console.print(f"[dim]Revoking token for {provider_name}...[/]")
47
+ try:
48
+ revoked = auth_provider.revoke(token)
49
+ except Exception: # pylint: disable=broad-exception-caught
50
+ # Best-effort revocation
51
+ if not quiet:
52
+ console.print("[dim]Token revocation not supported or failed.[/]")
53
+
54
+ # Clear token from storage
55
+ auth_provider.clear_token()
56
+
57
+ # Success message
58
+ if revoked:
59
+ message = f"Logged out from {provider_name} (token revoked)."
60
+ else:
61
+ message = f"Logged out from {provider_name} (token cleared locally)."
62
+
63
+ if quiet:
64
+ console.print(f"[green]{message}[/]")
65
+ else:
66
+ render_result(
67
+ CommandResult(
68
+ status=CommandStatus.OK,
69
+ message=message,
70
+ )
71
+ )
72
+
73
+
74
+ __all__ = ["logout"]
@@ -0,0 +1,57 @@
1
+ """List configured auth providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.table import Table
7
+
8
+ from kstlib.auth.config import (
9
+ get_default_provider_name,
10
+ get_provider_config,
11
+ list_configured_providers,
12
+ )
13
+ from kstlib.cli.common import console
14
+
15
+
16
+ def providers(
17
+ verbose: bool = typer.Option(
18
+ False,
19
+ "--verbose",
20
+ "-v",
21
+ help="Show detailed provider configuration.",
22
+ ),
23
+ ) -> None:
24
+ """List configured authentication providers."""
25
+ configured = list_configured_providers()
26
+ default = get_default_provider_name()
27
+
28
+ if not configured:
29
+ console.print("[yellow]No auth providers configured.[/]")
30
+ console.print("Configure providers in kstlib.conf.yml under 'auth.providers'.")
31
+ raise typer.Exit(0)
32
+
33
+ table = Table(title="Configured Auth Providers")
34
+ table.add_column("Provider", style="cyan")
35
+ table.add_column("Type", style="green")
36
+ if verbose:
37
+ table.add_column("Issuer / Endpoints", style="dim")
38
+ table.add_column("Default", style="yellow", justify="center")
39
+
40
+ for name in configured:
41
+ cfg = get_provider_config(name) or {}
42
+ provider_type = cfg.get("type", "oidc").upper()
43
+ is_default = "[bold]✓[/]" if name == default else ""
44
+
45
+ if verbose:
46
+ # Show issuer or authorization endpoint
47
+ issuer = cfg.get("issuer", "")
48
+ auth_endpoint = cfg.get("authorization_endpoint", cfg.get("authorize_url", ""))
49
+ endpoint_info = issuer or auth_endpoint or "[dim]not configured[/]"
50
+ table.add_row(name, provider_type, endpoint_info, is_default)
51
+ else:
52
+ table.add_row(name, provider_type, is_default)
53
+
54
+ console.print(table)
55
+
56
+
57
+ __all__ = ["providers"]
@@ -0,0 +1,291 @@
1
+ """Show authentication status for a provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from kstlib.auth.config import get_status_config
13
+ from kstlib.cli.common import console
14
+
15
+ from .common import PROVIDER_ARGUMENT, QUIET_OPTION, get_provider, resolve_provider_name
16
+
17
+ if TYPE_CHECKING:
18
+ from kstlib.auth.models import Token
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class _DisplayContext:
23
+ """Display context for status output."""
24
+
25
+ provider_name: str
26
+ status_text: str
27
+ status_style: str
28
+ threshold: int
29
+ refresh_threshold: int
30
+ use_local_tz: bool
31
+ is_expired: bool
32
+
33
+
34
+ def _format_duration(seconds: int) -> str:
35
+ """Format duration in human-readable form.
36
+
37
+ Args:
38
+ seconds: Duration in seconds (can be negative for past).
39
+
40
+ Returns:
41
+ Formatted duration string.
42
+ """
43
+ abs_seconds = abs(seconds)
44
+ if abs_seconds > 3600:
45
+ return f"{abs_seconds // 3600}h {(abs_seconds % 3600) // 60}m"
46
+ if abs_seconds > 60:
47
+ return f"{abs_seconds // 60}m {abs_seconds % 60}s"
48
+ return f"{abs_seconds}s"
49
+
50
+
51
+ def _format_datetime(dt: datetime, *, use_local: bool) -> str:
52
+ """Format datetime for display.
53
+
54
+ Args:
55
+ dt: Datetime to format (should be timezone-aware).
56
+ use_local: If True, convert to local timezone.
57
+
58
+ Returns:
59
+ Formatted datetime string.
60
+ """
61
+ if use_local:
62
+ local_dt = dt.astimezone()
63
+ return local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")
64
+ return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
65
+
66
+
67
+ def _get_refresh_token_expiry(token: Token) -> datetime | None:
68
+ """Extract refresh token expiry from token metadata.
69
+
70
+ Args:
71
+ token: Token object with potential refresh token info.
72
+
73
+ Returns:
74
+ Refresh token expiry datetime, or None if unknown.
75
+ """
76
+ metadata = token.metadata
77
+
78
+ # Check for refresh_expires_at (absolute timestamp)
79
+ if metadata.get("refresh_expires_at"):
80
+ try:
81
+ return datetime.fromisoformat(str(metadata["refresh_expires_at"]))
82
+ except (ValueError, TypeError):
83
+ pass
84
+
85
+ # Check refresh_expires_in from token response (relative)
86
+ if metadata.get("refresh_expires_in"):
87
+ try:
88
+ return token.issued_at + timedelta(seconds=int(metadata["refresh_expires_in"]))
89
+ except (ValueError, TypeError):
90
+ pass
91
+
92
+ return None
93
+
94
+
95
+ def _determine_status(token: Token, threshold: int) -> tuple[str, str, bool]:
96
+ """Determine token status text and style.
97
+
98
+ Args:
99
+ token: Token to check.
100
+ threshold: Expiring soon threshold in seconds.
101
+
102
+ Returns:
103
+ Tuple of (status_text, status_style, is_expired).
104
+ """
105
+ expires_in = token.expires_in
106
+ is_expired = token.is_expired
107
+
108
+ if is_expired:
109
+ return "[red]Expired[/]", "red", True
110
+ if expires_in is not None and expires_in <= threshold:
111
+ return "[yellow]Expiring soon[/]", "yellow", False
112
+ return "[green]Valid[/]", "green", False
113
+
114
+
115
+ def _build_access_token_rows(table: Table, token: Token, ctx: _DisplayContext) -> None:
116
+ """Add access token rows to the status table.
117
+
118
+ Args:
119
+ table: Rich table to add rows to.
120
+ token: Token object.
121
+ ctx: Display context.
122
+ """
123
+ table.add_row("Provider", f"[cyan]{ctx.provider_name}[/]")
124
+ table.add_row("Status", ctx.status_text)
125
+ table.add_row(
126
+ "Token Type",
127
+ token.token_type.value if hasattr(token.token_type, "value") else str(token.token_type),
128
+ )
129
+
130
+ if token.issued_at:
131
+ table.add_row("Issued At", _format_datetime(token.issued_at, use_local=ctx.use_local_tz))
132
+
133
+ if token.expires_at:
134
+ table.add_row("Expires At", _format_datetime(token.expires_at, use_local=ctx.use_local_tz))
135
+
136
+ expires_in = token.expires_in
137
+ if expires_in is not None:
138
+ if ctx.is_expired:
139
+ table.add_row("Expired Since", f"[red]{_format_duration(expires_in)}[/]")
140
+ else:
141
+ table.add_row("Expires In", _format_duration(expires_in))
142
+
143
+ if token.scope:
144
+ table.add_row("Scopes", " ".join(token.scope))
145
+
146
+
147
+ def _build_refresh_token_panel(token: Token, ctx: _DisplayContext) -> Panel | None:
148
+ """Build a separate panel for refresh token status.
149
+
150
+ Args:
151
+ token: Token object.
152
+ ctx: Display context.
153
+
154
+ Returns:
155
+ Panel for refresh token, or None if no refresh token.
156
+ """
157
+ if not token.is_refreshable:
158
+ return None
159
+
160
+ table = Table(show_header=False, box=None)
161
+ table.add_column("Field", style="dim")
162
+ table.add_column("Value")
163
+
164
+ refresh_expiry = _get_refresh_token_expiry(token)
165
+
166
+ if not refresh_expiry:
167
+ table.add_row("Status", "[green]Available[/]")
168
+ table.add_row("Expires At", "[dim]Unknown[/]")
169
+ return Panel(table, title="Refresh Token", style="green")
170
+
171
+ now = datetime.now(timezone.utc)
172
+ refresh_expires_in = int((refresh_expiry - now).total_seconds())
173
+
174
+ if refresh_expires_in <= 0:
175
+ status_text = "[red]Expired[/]"
176
+ panel_style = "red"
177
+ elif refresh_expires_in <= ctx.refresh_threshold:
178
+ status_text = "[yellow]Expiring soon[/]"
179
+ panel_style = "yellow"
180
+ else:
181
+ status_text = "[green]Valid[/]"
182
+ panel_style = "green"
183
+
184
+ table.add_row("Status", status_text)
185
+ table.add_row("Expires At", _format_datetime(refresh_expiry, use_local=ctx.use_local_tz))
186
+
187
+ if refresh_expires_in <= 0:
188
+ table.add_row("Expired Since", f"[red]{_format_duration(refresh_expires_in)}[/]")
189
+ else:
190
+ table.add_row("Expires In", _format_duration(refresh_expires_in))
191
+
192
+ return Panel(table, title="Refresh Token", style=panel_style)
193
+
194
+
195
+ def _show_not_authenticated(provider_name: str, quiet: bool) -> None:
196
+ """Display not authenticated message.
197
+
198
+ Args:
199
+ provider_name: Provider name.
200
+ quiet: Whether to use quiet mode.
201
+ """
202
+ if quiet:
203
+ console.print(f"[yellow]{provider_name}: not authenticated[/]")
204
+ else:
205
+ console.print(
206
+ Panel(
207
+ f"Not authenticated with [cyan]{provider_name}[/].\n"
208
+ f"Run [bold]kstlib auth login {provider_name}[/] to authenticate.",
209
+ title="Auth Status",
210
+ style="yellow",
211
+ )
212
+ )
213
+
214
+
215
+ def _show_quiet_status(provider_name: str, token: Token, status_text: str, is_expired: bool) -> None:
216
+ """Display quiet mode status.
217
+
218
+ Args:
219
+ provider_name: Provider name.
220
+ token: Token object.
221
+ status_text: Formatted status text.
222
+ is_expired: Whether token is expired.
223
+ """
224
+ expires_in = token.expires_in
225
+ if expires_in is not None:
226
+ duration = _format_duration(expires_in)
227
+ if is_expired:
228
+ console.print(f"{provider_name}: {status_text} (expired {duration} ago)")
229
+ else:
230
+ console.print(f"{provider_name}: {status_text} (expires in {duration})")
231
+ else:
232
+ console.print(f"{provider_name}: {status_text}")
233
+
234
+
235
+ def _show_verbose_status(token: Token, ctx: _DisplayContext) -> None:
236
+ """Display verbose mode status.
237
+
238
+ Args:
239
+ token: Token object.
240
+ ctx: Display context.
241
+ """
242
+ # Access token panel
243
+ table = Table(show_header=False, box=None)
244
+ table.add_column("Field", style="dim")
245
+ table.add_column("Value")
246
+ _build_access_token_rows(table, token, ctx)
247
+ console.print(Panel(table, title="Access Token", style=ctx.status_style))
248
+
249
+ # Refresh token panel (separate, with its own status color)
250
+ refresh_panel = _build_refresh_token_panel(token, ctx)
251
+ if refresh_panel:
252
+ console.print(refresh_panel)
253
+
254
+
255
+ def status(
256
+ provider: str | None = PROVIDER_ARGUMENT,
257
+ quiet: bool = QUIET_OPTION,
258
+ ) -> None:
259
+ """Show authentication status for a provider."""
260
+ provider_name = resolve_provider_name(provider)
261
+ auth_provider = get_provider(provider_name)
262
+ token = auth_provider.get_token(auto_refresh=False)
263
+
264
+ if token is None:
265
+ _show_not_authenticated(provider_name, quiet)
266
+ return
267
+
268
+ status_cfg = get_status_config()
269
+ threshold = status_cfg["expiring_soon_threshold"]
270
+ refresh_threshold = status_cfg["refresh_expiring_soon_threshold"]
271
+ use_local_tz = status_cfg["display_timezone"] == "local"
272
+
273
+ status_text, status_style, is_expired = _determine_status(token, threshold)
274
+
275
+ if quiet:
276
+ _show_quiet_status(provider_name, token, status_text, is_expired)
277
+ return
278
+
279
+ ctx = _DisplayContext(
280
+ provider_name=provider_name,
281
+ status_text=status_text,
282
+ status_style=status_style,
283
+ threshold=threshold,
284
+ refresh_threshold=refresh_threshold,
285
+ use_local_tz=use_local_tz,
286
+ is_expired=is_expired,
287
+ )
288
+ _show_verbose_status(token, ctx)
289
+
290
+
291
+ __all__ = ["status"]
@@ -0,0 +1,199 @@
1
+ """Show or copy the access token."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from typing import Any
8
+
9
+ import typer
10
+
11
+ from kstlib.cli.common import console, exit_error
12
+ from kstlib.utils.serialization import to_json
13
+
14
+ from .common import PROVIDER_ARGUMENT, get_provider, resolve_provider_name
15
+
16
+
17
+ def _decode_jwt(token_str: str) -> tuple[dict[str, Any], dict[str, Any]] | None:
18
+ """Decode a JWT and return (header, payload) dicts.
19
+
20
+ Returns None if token is not a valid JWT format.
21
+ """
22
+ parts = token_str.split(".")
23
+ if len(parts) != 3:
24
+ return None
25
+
26
+ try:
27
+ # Add padding if needed (base64url)
28
+ def decode_part(part: str) -> dict[str, Any]:
29
+ # Add padding
30
+ padding = 4 - len(part) % 4
31
+ if padding != 4:
32
+ part += "=" * padding
33
+ decoded = base64.urlsafe_b64decode(part)
34
+ return json.loads(decoded) # type: ignore[no-any-return]
35
+
36
+ header = decode_part(parts[0])
37
+ payload = decode_part(parts[1])
38
+ return header, payload
39
+ except Exception: # pylint: disable=broad-exception-caught
40
+ return None
41
+
42
+
43
+ def _format_decoded(
44
+ header: dict[str, Any],
45
+ payload: dict[str, Any],
46
+ *,
47
+ as_json: bool = False,
48
+ ) -> str:
49
+ """Format decoded JWT for display."""
50
+ if as_json:
51
+ return to_json({"header": header, "payload": payload})
52
+
53
+ # YAML-like format (more readable)
54
+ lines = ["[bold cyan]--- JWT Header ---[/]"]
55
+ for key, value in header.items():
56
+ lines.append(f"[dim]{key}:[/] {value}")
57
+
58
+ lines.append("")
59
+ lines.append("[bold cyan]--- JWT Payload ---[/]")
60
+ for key, value in payload.items():
61
+ # Special formatting for timestamps
62
+ if key in ("exp", "iat", "auth_time", "nbf") and isinstance(value, int):
63
+ from datetime import datetime, timezone
64
+
65
+ dt = datetime.fromtimestamp(value, tz=timezone.utc)
66
+ lines.append(f"[dim]{key}:[/] {value} [dim]({dt.isoformat()})[/]")
67
+ elif isinstance(value, list):
68
+ lines.append(f"[dim]{key}:[/] {', '.join(str(v) for v in value)}")
69
+ else:
70
+ lines.append(f"[dim]{key}:[/] {value}")
71
+
72
+ return "\n".join(lines)
73
+
74
+
75
+ def token(
76
+ provider: str | None = PROVIDER_ARGUMENT,
77
+ copy: bool = typer.Option(
78
+ False,
79
+ "--copy",
80
+ "-c",
81
+ help="Copy token to clipboard instead of printing.",
82
+ ),
83
+ refresh: bool = typer.Option(
84
+ False,
85
+ "--refresh",
86
+ "-r",
87
+ help="Force refresh token before displaying.",
88
+ ),
89
+ show_refresh: bool = typer.Option(
90
+ False,
91
+ "--show-refresh",
92
+ help="Show the refresh token instead of access token.",
93
+ ),
94
+ decode: bool = typer.Option(
95
+ False,
96
+ "--decode",
97
+ "-d",
98
+ help="Decode JWT and show header + payload (human-readable).",
99
+ ),
100
+ as_json: bool = typer.Option(
101
+ False,
102
+ "--json",
103
+ "-j",
104
+ help="Output decoded JWT as JSON (requires --decode).",
105
+ ),
106
+ header: bool = typer.Option(
107
+ False,
108
+ "--header",
109
+ "-H",
110
+ help="Output as Authorization header value (access token only).",
111
+ ),
112
+ ) -> None:
113
+ """Show or copy the current access token.
114
+
115
+ By default, prints the raw access token. Use --header to get the
116
+ full Authorization header value (e.g., 'Bearer <token>').
117
+
118
+ Use --show-refresh to display the refresh token instead.
119
+
120
+ Use --decode to view the JWT header and payload in a readable format.
121
+ """
122
+ provider_name = resolve_provider_name(provider)
123
+ auth_provider = get_provider(provider_name)
124
+
125
+ # Force refresh if requested
126
+ if refresh:
127
+ current_token = auth_provider.get_token(auto_refresh=False)
128
+ if current_token is None:
129
+ exit_error(f"Not authenticated with {provider_name}.\nRun 'kstlib auth login {provider_name}' first.")
130
+ if not current_token.is_refreshable:
131
+ exit_error("Token cannot be refreshed (no refresh_token).")
132
+ try:
133
+ current_token = auth_provider.refresh(current_token)
134
+ except Exception as e: # pylint: disable=broad-exception-caught
135
+ exit_error(f"Token refresh failed: {e}")
136
+ else:
137
+ current_token = auth_provider.get_token(auto_refresh=True)
138
+
139
+ if current_token is None:
140
+ exit_error(f"Not authenticated with {provider_name}.\nRun 'kstlib auth login {provider_name}' first.")
141
+
142
+ # Validate incompatible options
143
+ if as_json and not decode:
144
+ exit_error("--json requires --decode.")
145
+ if decode and header:
146
+ exit_error("--decode and --header cannot be used together.")
147
+ if decode and copy:
148
+ exit_error("--decode and --copy cannot be used together.")
149
+
150
+ # Handle --show-refresh
151
+ if show_refresh:
152
+ if header:
153
+ exit_error("--header cannot be used with --show-refresh (refresh tokens are not used in headers).")
154
+ if not current_token.refresh_token:
155
+ exit_error("No refresh token available for this session.")
156
+ raw_token = current_token.refresh_token
157
+ else:
158
+ raw_token = current_token.access_token
159
+
160
+ # Handle --decode
161
+ if decode:
162
+ decoded = _decode_jwt(raw_token)
163
+ if decoded is None:
164
+ exit_error("Token is not a valid JWT format.")
165
+ jwt_header, jwt_payload = decoded
166
+ output = _format_decoded(jwt_header, jwt_payload, as_json=as_json)
167
+ if as_json:
168
+ print(output)
169
+ else:
170
+ console.print(output)
171
+ return
172
+
173
+ # Format output for raw token
174
+ if header:
175
+ token_type = (
176
+ current_token.token_type.value
177
+ if hasattr(current_token.token_type, "value")
178
+ else str(current_token.token_type)
179
+ )
180
+ output = f"{token_type} {raw_token}"
181
+ else:
182
+ output = raw_token
183
+
184
+ if copy:
185
+ try:
186
+ import pyperclip # type: ignore[import-untyped]
187
+
188
+ pyperclip.copy(output)
189
+ console.print(f"[green]Token copied to clipboard ({provider_name}).[/]")
190
+ except ImportError:
191
+ exit_error("Clipboard support requires pyperclip.\nInstall with: pip install pyperclip")
192
+ except Exception as e: # pylint: disable=broad-exception-caught
193
+ exit_error(f"Failed to copy to clipboard: {e}")
194
+ else:
195
+ # Print raw token (no Rich formatting for easy piping)
196
+ print(output)
197
+
198
+
199
+ __all__ = ["token"]