kctl-api 0.2.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.
- kctl_api/__init__.py +3 -0
- kctl_api/__main__.py +5 -0
- kctl_api/cli.py +238 -0
- kctl_api/commands/__init__.py +1 -0
- kctl_api/commands/ai.py +250 -0
- kctl_api/commands/aliases.py +84 -0
- kctl_api/commands/apps.py +172 -0
- kctl_api/commands/auth.py +313 -0
- kctl_api/commands/automation.py +242 -0
- kctl_api/commands/build_cmd.py +87 -0
- kctl_api/commands/clean.py +182 -0
- kctl_api/commands/config_cmd.py +443 -0
- kctl_api/commands/dashboard.py +139 -0
- kctl_api/commands/db.py +599 -0
- kctl_api/commands/deploy.py +84 -0
- kctl_api/commands/deps.py +289 -0
- kctl_api/commands/dev.py +136 -0
- kctl_api/commands/docker_cmd.py +252 -0
- kctl_api/commands/doctor_cmd.py +286 -0
- kctl_api/commands/env.py +289 -0
- kctl_api/commands/files.py +250 -0
- kctl_api/commands/fmt_cmd.py +58 -0
- kctl_api/commands/health.py +479 -0
- kctl_api/commands/jobs.py +169 -0
- kctl_api/commands/lint_cmd.py +81 -0
- kctl_api/commands/logs.py +258 -0
- kctl_api/commands/marketplace.py +316 -0
- kctl_api/commands/monitor_cmd.py +243 -0
- kctl_api/commands/notifications.py +132 -0
- kctl_api/commands/odoo_proxy.py +182 -0
- kctl_api/commands/openapi.py +299 -0
- kctl_api/commands/perf.py +307 -0
- kctl_api/commands/rate_limit.py +223 -0
- kctl_api/commands/realtime.py +100 -0
- kctl_api/commands/redis_cmd.py +609 -0
- kctl_api/commands/routes_cmd.py +277 -0
- kctl_api/commands/saas.py +145 -0
- kctl_api/commands/scaffold.py +362 -0
- kctl_api/commands/security_cmd.py +350 -0
- kctl_api/commands/services.py +191 -0
- kctl_api/commands/shell.py +197 -0
- kctl_api/commands/skill_cmd.py +58 -0
- kctl_api/commands/streams.py +309 -0
- kctl_api/commands/stripe_cmd.py +105 -0
- kctl_api/commands/tenant_ai.py +169 -0
- kctl_api/commands/test_cmd.py +95 -0
- kctl_api/commands/users.py +302 -0
- kctl_api/commands/webhooks.py +56 -0
- kctl_api/commands/workflows.py +127 -0
- kctl_api/commands/ws.py +323 -0
- kctl_api/core/__init__.py +1 -0
- kctl_api/core/async_client.py +120 -0
- kctl_api/core/callbacks.py +88 -0
- kctl_api/core/client.py +190 -0
- kctl_api/core/config.py +260 -0
- kctl_api/core/db.py +65 -0
- kctl_api/core/exceptions.py +43 -0
- kctl_api/core/output.py +5 -0
- kctl_api/core/plugins.py +26 -0
- kctl_api/core/redis.py +35 -0
- kctl_api/core/resolve.py +47 -0
- kctl_api/core/utils.py +109 -0
- kctl_api-0.2.0.dist-info/METADATA +34 -0
- kctl_api-0.2.0.dist-info/RECORD +66 -0
- kctl_api-0.2.0.dist-info/WHEEL +4 -0
- kctl_api-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""App registry commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Enumerate and inspect known apps in the kodemeio-fastapi monorepo.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_api.core.callbacks import AppContext
|
|
13
|
+
from kctl_api.core.utils import KNOWN_APPS
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(name="apps", help="App registry — list and inspect monorepo apps.", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# list
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
@app.command(name="list")
|
|
22
|
+
def list_apps(ctx: typer.Context) -> None:
|
|
23
|
+
"""List all known apps in the kodemeio-fastapi monorepo."""
|
|
24
|
+
actx: AppContext = ctx.obj
|
|
25
|
+
out = actx.output
|
|
26
|
+
|
|
27
|
+
rows: list[list[str]] = []
|
|
28
|
+
json_data: list[dict] = []
|
|
29
|
+
|
|
30
|
+
for name, meta in KNOWN_APPS.items():
|
|
31
|
+
port = meta.get("port", "")
|
|
32
|
+
app_type = meta.get("type", "")
|
|
33
|
+
module = meta.get("module", "")
|
|
34
|
+
rows.append([name, app_type, port or "-", module])
|
|
35
|
+
json_data.append({"name": name, "type": app_type, "port": port, "module": module})
|
|
36
|
+
|
|
37
|
+
out.table(
|
|
38
|
+
title=f"Known Apps ({len(KNOWN_APPS)})",
|
|
39
|
+
columns=[
|
|
40
|
+
("Name", "bold"),
|
|
41
|
+
("Type", ""),
|
|
42
|
+
("Port", ""),
|
|
43
|
+
("Module", "dim"),
|
|
44
|
+
],
|
|
45
|
+
rows=rows,
|
|
46
|
+
data_for_json=json_data,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# info
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
@app.command()
|
|
54
|
+
def info(
|
|
55
|
+
ctx: typer.Context,
|
|
56
|
+
name: Annotated[str, typer.Argument(help="App name.")],
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Show details for a specific app from the registry."""
|
|
59
|
+
actx: AppContext = ctx.obj
|
|
60
|
+
out = actx.output
|
|
61
|
+
|
|
62
|
+
meta = KNOWN_APPS.get(name)
|
|
63
|
+
if not meta:
|
|
64
|
+
out.error(f"Unknown app: {name}. Run 'kctl-api apps list' to see available apps.")
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
|
|
67
|
+
data = {"name": name, **meta}
|
|
68
|
+
|
|
69
|
+
out.detail(
|
|
70
|
+
title=f"App: {name}",
|
|
71
|
+
sections=[
|
|
72
|
+
(
|
|
73
|
+
"Details",
|
|
74
|
+
[
|
|
75
|
+
("Name", name),
|
|
76
|
+
("Type", meta.get("type", "")),
|
|
77
|
+
("Port", meta.get("port", "") or "(none)"),
|
|
78
|
+
("Module", meta.get("module", "")),
|
|
79
|
+
],
|
|
80
|
+
),
|
|
81
|
+
],
|
|
82
|
+
data_for_json=data,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# env
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
@app.command()
|
|
90
|
+
def env(
|
|
91
|
+
ctx: typer.Context,
|
|
92
|
+
name: Annotated[str, typer.Argument(help="App name.")],
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Show environment variables for an app (reads .env.example)."""
|
|
95
|
+
actx: AppContext = ctx.obj
|
|
96
|
+
out = actx.output
|
|
97
|
+
|
|
98
|
+
from kctl_api.core.utils import find_project_root
|
|
99
|
+
|
|
100
|
+
meta = KNOWN_APPS.get(name)
|
|
101
|
+
if not meta:
|
|
102
|
+
out.error(f"Unknown app: {name}")
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
|
|
105
|
+
root = find_project_root()
|
|
106
|
+
env_example = root / "apps" / name / ".env.example"
|
|
107
|
+
if not env_example.exists():
|
|
108
|
+
env_example = root / ".env.example"
|
|
109
|
+
|
|
110
|
+
if not env_example.exists():
|
|
111
|
+
out.info(f"No .env.example found for {name}.")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
content = env_example.read_text()
|
|
115
|
+
if actx.json_mode:
|
|
116
|
+
lines = [line.strip() for line in content.splitlines() if line.strip() and not line.startswith("#")]
|
|
117
|
+
out.raw_json([{"var": line.split("=", 1)[0], "example": line} for line in lines])
|
|
118
|
+
else:
|
|
119
|
+
out.header(f"Environment: {name}")
|
|
120
|
+
out.text(content)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# deps
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
@app.command()
|
|
127
|
+
def deps(
|
|
128
|
+
ctx: typer.Context,
|
|
129
|
+
name: Annotated[str, typer.Argument(help="App name.")],
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Show dependencies for an app (reads pyproject.toml)."""
|
|
132
|
+
actx: AppContext = ctx.obj
|
|
133
|
+
out = actx.output
|
|
134
|
+
|
|
135
|
+
from kctl_api.core.utils import find_project_root
|
|
136
|
+
|
|
137
|
+
meta = KNOWN_APPS.get(name)
|
|
138
|
+
if not meta:
|
|
139
|
+
out.error(f"Unknown app: {name}")
|
|
140
|
+
raise typer.Exit(1)
|
|
141
|
+
|
|
142
|
+
root = find_project_root()
|
|
143
|
+
pyproject = root / "apps" / name / "pyproject.toml"
|
|
144
|
+
if not pyproject.exists():
|
|
145
|
+
out.info(f"No pyproject.toml found for {name}.")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
content = pyproject.read_text()
|
|
149
|
+
|
|
150
|
+
# Simple TOML parsing for dependencies section
|
|
151
|
+
in_deps = False
|
|
152
|
+
deps_list: list[str] = []
|
|
153
|
+
for line in content.splitlines():
|
|
154
|
+
if line.strip().startswith("[project]"):
|
|
155
|
+
continue
|
|
156
|
+
if "dependencies" in line and "=" in line:
|
|
157
|
+
in_deps = True
|
|
158
|
+
continue
|
|
159
|
+
if in_deps:
|
|
160
|
+
if line.strip().startswith("]"):
|
|
161
|
+
break
|
|
162
|
+
dep = line.strip().strip('",').strip("'")
|
|
163
|
+
if dep:
|
|
164
|
+
deps_list.append(dep)
|
|
165
|
+
|
|
166
|
+
if actx.json_mode:
|
|
167
|
+
out.raw_json({"app": name, "dependencies": deps_list})
|
|
168
|
+
else:
|
|
169
|
+
out.header(f"Dependencies: {name}")
|
|
170
|
+
for dep in deps_list:
|
|
171
|
+
out.text(f" {dep}")
|
|
172
|
+
out.info(f"{len(deps_list)} dependencies.")
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Authentication commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Login, logout, token refresh, and user identity inspection.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from kctl_api.core.callbacks import AppContext
|
|
16
|
+
from kctl_api.core.config import (
|
|
17
|
+
ServiceConfig,
|
|
18
|
+
get_service_config,
|
|
19
|
+
resolve_active_profile_name,
|
|
20
|
+
set_service_config,
|
|
21
|
+
)
|
|
22
|
+
from kctl_api.core.exceptions import APIError, AuthenticationError
|
|
23
|
+
from kctl_api.core.exceptions import ConnectionError as KctlConnectionError
|
|
24
|
+
from kctl_api.core.utils import mask_secret, role_color, tier_color
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(name="auth", help="Authentication and identity management.", no_args_is_help=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# login
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
@app.command()
|
|
33
|
+
def login(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
email: Annotated[str | None, typer.Option("--email", "-e", help="Account email.")] = None,
|
|
36
|
+
password: Annotated[str | None, typer.Option("--password", "-p", help="Account password.", hide_input=True)] = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Authenticate with email and password, cache JWT tokens in profile."""
|
|
39
|
+
actx: AppContext = ctx.obj
|
|
40
|
+
out = actx.output
|
|
41
|
+
|
|
42
|
+
# Interactive prompts if not provided via flags
|
|
43
|
+
if not email:
|
|
44
|
+
email = typer.prompt("Email")
|
|
45
|
+
if not password:
|
|
46
|
+
password = typer.prompt("Password", hide_input=True)
|
|
47
|
+
|
|
48
|
+
out.info("Authenticating ...")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
tokens = actx.client.login(email, password)
|
|
52
|
+
except AuthenticationError as e:
|
|
53
|
+
out.error(f"Login failed: {e}")
|
|
54
|
+
raise typer.Exit(1) from None
|
|
55
|
+
except KctlConnectionError as e:
|
|
56
|
+
out.error(f"Connection failed: {e}")
|
|
57
|
+
raise typer.Exit(1) from None
|
|
58
|
+
except APIError as e:
|
|
59
|
+
out.error(f"API error: {e.detail}")
|
|
60
|
+
raise typer.Exit(1) from None
|
|
61
|
+
|
|
62
|
+
# Persist the access token into the profile config
|
|
63
|
+
profile_name = resolve_active_profile_name(actx.profile)
|
|
64
|
+
svc = get_service_config(profile_name)
|
|
65
|
+
updated = ServiceConfig(
|
|
66
|
+
url=svc.url,
|
|
67
|
+
ai_url=svc.ai_url,
|
|
68
|
+
api_key=tokens.get("access_token", svc.api_key),
|
|
69
|
+
database_url=svc.database_url,
|
|
70
|
+
redis_url=svc.redis_url,
|
|
71
|
+
)
|
|
72
|
+
set_service_config(profile_name, updated)
|
|
73
|
+
|
|
74
|
+
out.success(f"Logged in as {email} (profile: {profile_name}).")
|
|
75
|
+
|
|
76
|
+
if actx.json_mode:
|
|
77
|
+
out.raw_json(
|
|
78
|
+
{
|
|
79
|
+
"email": email,
|
|
80
|
+
"profile": profile_name,
|
|
81
|
+
"access_token": mask_secret(tokens.get("access_token", "")),
|
|
82
|
+
"has_refresh_token": bool(tokens.get("refresh_token")),
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# logout
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
@app.command()
|
|
91
|
+
def logout(ctx: typer.Context) -> None:
|
|
92
|
+
"""Clear cached tokens from the active profile."""
|
|
93
|
+
actx: AppContext = ctx.obj
|
|
94
|
+
out = actx.output
|
|
95
|
+
|
|
96
|
+
# Try server-side logout (best-effort)
|
|
97
|
+
import contextlib
|
|
98
|
+
|
|
99
|
+
with contextlib.suppress(Exception):
|
|
100
|
+
actx.client.post("/api/v1/auth/logout")
|
|
101
|
+
|
|
102
|
+
# Clear api_key (JWT token) from profile
|
|
103
|
+
profile_name = resolve_active_profile_name(actx.profile)
|
|
104
|
+
svc = get_service_config(profile_name)
|
|
105
|
+
updated = ServiceConfig(
|
|
106
|
+
url=svc.url,
|
|
107
|
+
ai_url=svc.ai_url,
|
|
108
|
+
api_key="",
|
|
109
|
+
database_url=svc.database_url,
|
|
110
|
+
redis_url=svc.redis_url,
|
|
111
|
+
)
|
|
112
|
+
set_service_config(profile_name, updated)
|
|
113
|
+
|
|
114
|
+
out.success(f"Logged out (profile: {profile_name}). Tokens cleared.")
|
|
115
|
+
|
|
116
|
+
if actx.json_mode:
|
|
117
|
+
out.raw_json({"profile": profile_name, "logged_out": True})
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# refresh
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
@app.command()
|
|
124
|
+
def refresh(ctx: typer.Context) -> None:
|
|
125
|
+
"""Refresh the JWT access token using the refresh token."""
|
|
126
|
+
actx: AppContext = ctx.obj
|
|
127
|
+
out = actx.output
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
tokens = actx.client.refresh()
|
|
131
|
+
except AuthenticationError as e:
|
|
132
|
+
out.error(f"Refresh failed: {e}")
|
|
133
|
+
raise typer.Exit(1) from None
|
|
134
|
+
except KctlConnectionError as e:
|
|
135
|
+
out.error(f"Connection failed: {e}")
|
|
136
|
+
raise typer.Exit(1) from None
|
|
137
|
+
except APIError as e:
|
|
138
|
+
out.error(f"API error: {e.detail}")
|
|
139
|
+
raise typer.Exit(1) from None
|
|
140
|
+
|
|
141
|
+
# Update stored token
|
|
142
|
+
new_token = tokens.get("access_token", "")
|
|
143
|
+
if new_token:
|
|
144
|
+
profile_name = resolve_active_profile_name(actx.profile)
|
|
145
|
+
svc = get_service_config(profile_name)
|
|
146
|
+
updated = ServiceConfig(
|
|
147
|
+
url=svc.url,
|
|
148
|
+
ai_url=svc.ai_url,
|
|
149
|
+
api_key=new_token,
|
|
150
|
+
database_url=svc.database_url,
|
|
151
|
+
redis_url=svc.redis_url,
|
|
152
|
+
)
|
|
153
|
+
set_service_config(profile_name, updated)
|
|
154
|
+
out.success("Token refreshed and saved.")
|
|
155
|
+
else:
|
|
156
|
+
out.warn("Server returned no access token.")
|
|
157
|
+
|
|
158
|
+
if actx.json_mode:
|
|
159
|
+
out.raw_json(
|
|
160
|
+
{
|
|
161
|
+
"refreshed": bool(new_token),
|
|
162
|
+
"access_token": mask_secret(new_token) if new_token else None,
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# whoami
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
@app.command()
|
|
171
|
+
def whoami(ctx: typer.Context) -> None:
|
|
172
|
+
"""Display current authenticated user info."""
|
|
173
|
+
actx: AppContext = ctx.obj
|
|
174
|
+
out = actx.output
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
user = actx.client.get("/api/v1/auth/me")
|
|
178
|
+
except AuthenticationError as e:
|
|
179
|
+
out.error(f"Not authenticated: {e}")
|
|
180
|
+
raise typer.Exit(1) from None
|
|
181
|
+
except KctlConnectionError as e:
|
|
182
|
+
out.error(f"Connection failed: {e}")
|
|
183
|
+
raise typer.Exit(1) from None
|
|
184
|
+
except APIError as e:
|
|
185
|
+
out.error(f"API error: {e.detail}")
|
|
186
|
+
raise typer.Exit(1) from None
|
|
187
|
+
|
|
188
|
+
if not user:
|
|
189
|
+
out.error("No user data returned.")
|
|
190
|
+
raise typer.Exit(1)
|
|
191
|
+
|
|
192
|
+
role = user.get("role", "unknown")
|
|
193
|
+
tier = user.get("tier", "unknown")
|
|
194
|
+
|
|
195
|
+
out.detail(
|
|
196
|
+
title="Current User",
|
|
197
|
+
sections=[
|
|
198
|
+
(
|
|
199
|
+
"Identity",
|
|
200
|
+
[
|
|
201
|
+
("ID", str(user.get("id", ""))),
|
|
202
|
+
("Email", user.get("email", "")),
|
|
203
|
+
("Name", user.get("name", user.get("full_name", ""))),
|
|
204
|
+
],
|
|
205
|
+
),
|
|
206
|
+
(
|
|
207
|
+
"Access",
|
|
208
|
+
[
|
|
209
|
+
("Role", role_color(role)),
|
|
210
|
+
("Tier", tier_color(tier)),
|
|
211
|
+
("Active", str(user.get("is_active", ""))),
|
|
212
|
+
],
|
|
213
|
+
),
|
|
214
|
+
],
|
|
215
|
+
data_for_json=user,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# token-info
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
def _decode_jwt_payload(token: str) -> dict:
|
|
223
|
+
"""Decode JWT payload without verification (base64 only, no PyJWT)."""
|
|
224
|
+
parts = token.split(".")
|
|
225
|
+
if len(parts) != 3:
|
|
226
|
+
raise ValueError("Invalid JWT format: expected 3 dot-separated parts.")
|
|
227
|
+
|
|
228
|
+
# Decode the payload (second part)
|
|
229
|
+
payload_b64 = parts[1]
|
|
230
|
+
# Add padding if needed
|
|
231
|
+
padding = 4 - len(payload_b64) % 4
|
|
232
|
+
if padding != 4:
|
|
233
|
+
payload_b64 += "=" * padding
|
|
234
|
+
|
|
235
|
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
236
|
+
return json.loads(payload_bytes)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _format_timestamp(ts: int | float | None) -> str:
|
|
240
|
+
"""Format a Unix timestamp to human-readable UTC string."""
|
|
241
|
+
if ts is None:
|
|
242
|
+
return "(not set)"
|
|
243
|
+
try:
|
|
244
|
+
dt = datetime.fromtimestamp(float(ts), tz=UTC)
|
|
245
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
246
|
+
except (OSError, ValueError):
|
|
247
|
+
return str(ts)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.command(name="token-info")
|
|
251
|
+
def token_info(
|
|
252
|
+
ctx: typer.Context,
|
|
253
|
+
token: Annotated[
|
|
254
|
+
str | None, typer.Option("--token", "-t", help="JWT token to inspect (default: from profile).")
|
|
255
|
+
] = None,
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Decode and display JWT token claims (without cryptographic verification)."""
|
|
258
|
+
actx: AppContext = ctx.obj
|
|
259
|
+
out = actx.output
|
|
260
|
+
|
|
261
|
+
# Resolve token: explicit flag > profile config
|
|
262
|
+
if not token:
|
|
263
|
+
profile_name = resolve_active_profile_name(actx.profile)
|
|
264
|
+
svc = get_service_config(profile_name)
|
|
265
|
+
token = svc.api_key
|
|
266
|
+
|
|
267
|
+
if not token:
|
|
268
|
+
out.error("No token available. Login first: kctl-api auth login")
|
|
269
|
+
raise typer.Exit(1)
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
claims = _decode_jwt_payload(token)
|
|
273
|
+
except (ValueError, json.JSONDecodeError) as e:
|
|
274
|
+
out.error(f"Failed to decode token: {e}")
|
|
275
|
+
raise typer.Exit(1) from None
|
|
276
|
+
|
|
277
|
+
# Determine expiry status
|
|
278
|
+
exp = claims.get("exp")
|
|
279
|
+
now_ts = datetime.now(tz=UTC).timestamp()
|
|
280
|
+
expired = exp is not None and float(exp) < now_ts
|
|
281
|
+
exp_display = _format_timestamp(exp)
|
|
282
|
+
if expired:
|
|
283
|
+
exp_display = f"[red]{exp_display} (EXPIRED)[/red]"
|
|
284
|
+
|
|
285
|
+
iat_display = _format_timestamp(claims.get("iat"))
|
|
286
|
+
|
|
287
|
+
sections: list[tuple[str, list[tuple[str, str]]]] = [
|
|
288
|
+
(
|
|
289
|
+
"Token Claims",
|
|
290
|
+
[
|
|
291
|
+
("Subject (sub)", str(claims.get("sub", "(none)"))),
|
|
292
|
+
("Email", str(claims.get("email", "(none)"))),
|
|
293
|
+
("Role", str(claims.get("role", "(none)"))),
|
|
294
|
+
("Tier", str(claims.get("tier", "(none)"))),
|
|
295
|
+
("Issued At (iat)", iat_display),
|
|
296
|
+
("Expires (exp)", exp_display),
|
|
297
|
+
("Issuer (iss)", str(claims.get("iss", "(none)"))),
|
|
298
|
+
("Token Type", str(claims.get("type", claims.get("token_type", "(none)")))),
|
|
299
|
+
],
|
|
300
|
+
),
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
# Show all extra claims not already displayed
|
|
304
|
+
known_keys = {"sub", "email", "role", "tier", "iat", "exp", "iss", "type", "token_type"}
|
|
305
|
+
extra = {k: v for k, v in claims.items() if k not in known_keys}
|
|
306
|
+
if extra:
|
|
307
|
+
sections.append(
|
|
308
|
+
("Additional Claims", [(k, str(v)) for k, v in sorted(extra.items())]),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
json_data = {**claims, "_expired": expired, "_exp_formatted": _format_timestamp(exp)}
|
|
312
|
+
|
|
313
|
+
out.detail(title="JWT Token Info", sections=sections, data_for_json=json_data)
|