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.
Files changed (66) hide show
  1. kctl_api/__init__.py +3 -0
  2. kctl_api/__main__.py +5 -0
  3. kctl_api/cli.py +238 -0
  4. kctl_api/commands/__init__.py +1 -0
  5. kctl_api/commands/ai.py +250 -0
  6. kctl_api/commands/aliases.py +84 -0
  7. kctl_api/commands/apps.py +172 -0
  8. kctl_api/commands/auth.py +313 -0
  9. kctl_api/commands/automation.py +242 -0
  10. kctl_api/commands/build_cmd.py +87 -0
  11. kctl_api/commands/clean.py +182 -0
  12. kctl_api/commands/config_cmd.py +443 -0
  13. kctl_api/commands/dashboard.py +139 -0
  14. kctl_api/commands/db.py +599 -0
  15. kctl_api/commands/deploy.py +84 -0
  16. kctl_api/commands/deps.py +289 -0
  17. kctl_api/commands/dev.py +136 -0
  18. kctl_api/commands/docker_cmd.py +252 -0
  19. kctl_api/commands/doctor_cmd.py +286 -0
  20. kctl_api/commands/env.py +289 -0
  21. kctl_api/commands/files.py +250 -0
  22. kctl_api/commands/fmt_cmd.py +58 -0
  23. kctl_api/commands/health.py +479 -0
  24. kctl_api/commands/jobs.py +169 -0
  25. kctl_api/commands/lint_cmd.py +81 -0
  26. kctl_api/commands/logs.py +258 -0
  27. kctl_api/commands/marketplace.py +316 -0
  28. kctl_api/commands/monitor_cmd.py +243 -0
  29. kctl_api/commands/notifications.py +132 -0
  30. kctl_api/commands/odoo_proxy.py +182 -0
  31. kctl_api/commands/openapi.py +299 -0
  32. kctl_api/commands/perf.py +307 -0
  33. kctl_api/commands/rate_limit.py +223 -0
  34. kctl_api/commands/realtime.py +100 -0
  35. kctl_api/commands/redis_cmd.py +609 -0
  36. kctl_api/commands/routes_cmd.py +277 -0
  37. kctl_api/commands/saas.py +145 -0
  38. kctl_api/commands/scaffold.py +362 -0
  39. kctl_api/commands/security_cmd.py +350 -0
  40. kctl_api/commands/services.py +191 -0
  41. kctl_api/commands/shell.py +197 -0
  42. kctl_api/commands/skill_cmd.py +58 -0
  43. kctl_api/commands/streams.py +309 -0
  44. kctl_api/commands/stripe_cmd.py +105 -0
  45. kctl_api/commands/tenant_ai.py +169 -0
  46. kctl_api/commands/test_cmd.py +95 -0
  47. kctl_api/commands/users.py +302 -0
  48. kctl_api/commands/webhooks.py +56 -0
  49. kctl_api/commands/workflows.py +127 -0
  50. kctl_api/commands/ws.py +323 -0
  51. kctl_api/core/__init__.py +1 -0
  52. kctl_api/core/async_client.py +120 -0
  53. kctl_api/core/callbacks.py +88 -0
  54. kctl_api/core/client.py +190 -0
  55. kctl_api/core/config.py +260 -0
  56. kctl_api/core/db.py +65 -0
  57. kctl_api/core/exceptions.py +43 -0
  58. kctl_api/core/output.py +5 -0
  59. kctl_api/core/plugins.py +26 -0
  60. kctl_api/core/redis.py +35 -0
  61. kctl_api/core/resolve.py +47 -0
  62. kctl_api/core/utils.py +109 -0
  63. kctl_api-0.2.0.dist-info/METADATA +34 -0
  64. kctl_api-0.2.0.dist-info/RECORD +66 -0
  65. kctl_api-0.2.0.dist-info/WHEEL +4 -0
  66. 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)