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,277 @@
1
+ """Route introspection commands for kctl-api.
2
+
3
+ Inspect endpoints, auth requirements, and middleware chains from the OpenAPI spec.
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
+
14
+ app = typer.Typer(
15
+ name="routes", help="Endpoint introspection — list, auth, middleware, unprotected.", no_args_is_help=True
16
+ )
17
+
18
+ _AUTH_KEYWORDS = ("bearer", "api_key", "apikey", "oauth", "security", "authorization")
19
+
20
+
21
+ def _fetch_spec(actx: AppContext) -> dict:
22
+ data = actx.client.get("/openapi.json")
23
+ if not data:
24
+ raise RuntimeError("Empty response from /openapi.json")
25
+ return data
26
+
27
+
28
+ def _is_protected(operation: dict) -> bool:
29
+ """Return True if the operation has a security requirement."""
30
+ security = operation.get("security")
31
+ if security is not None:
32
+ return bool(security) # empty list [] means explicitly no auth
33
+ return False
34
+
35
+
36
+ def _extract_routes(spec: dict) -> list[dict]:
37
+ """Extract all routes from an OpenAPI spec."""
38
+ routes = []
39
+ for path, path_item in spec.get("paths", {}).items():
40
+ for method, operation in path_item.items():
41
+ if method not in ("get", "post", "put", "patch", "delete", "head", "options"):
42
+ continue
43
+ routes.append(
44
+ {
45
+ "method": method.upper(),
46
+ "path": path,
47
+ "operation_id": operation.get("operationId", ""),
48
+ "summary": operation.get("summary", ""),
49
+ "tags": operation.get("tags", []),
50
+ "protected": _is_protected(operation),
51
+ "security": operation.get("security", []),
52
+ "deprecated": operation.get("deprecated", False),
53
+ }
54
+ )
55
+ return routes
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # list
60
+ # ---------------------------------------------------------------------------
61
+ @app.command(name="list")
62
+ def list_routes(ctx: typer.Context) -> None:
63
+ """List all API endpoints with methods, paths, and auth status."""
64
+ actx: AppContext = ctx.obj
65
+ out = actx.output
66
+
67
+ try:
68
+ spec = _fetch_spec(actx)
69
+ except Exception as e:
70
+ out.error(f"Failed to fetch spec: {e}")
71
+ raise typer.Exit(1) from None
72
+
73
+ routes = _extract_routes(spec)
74
+
75
+ rows = [
76
+ [
77
+ r["method"],
78
+ r["path"],
79
+ "[green]yes[/green]" if r["protected"] else "[yellow]no[/yellow]",
80
+ "[red]deprecated[/red]" if r["deprecated"] else "",
81
+ ", ".join(r["tags"]),
82
+ r["summary"][:60],
83
+ ]
84
+ for r in routes
85
+ ]
86
+
87
+ out.table(
88
+ title=f"API Routes ({len(routes)})",
89
+ columns=[
90
+ ("Method", "bold"),
91
+ ("Path", ""),
92
+ ("Auth", ""),
93
+ ("Status", ""),
94
+ ("Tags", ""),
95
+ ("Summary", ""),
96
+ ],
97
+ rows=rows,
98
+ data_for_json=routes,
99
+ )
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # auth
104
+ # ---------------------------------------------------------------------------
105
+ @app.command()
106
+ def auth(ctx: typer.Context) -> None:
107
+ """Group endpoints by authentication requirement."""
108
+ actx: AppContext = ctx.obj
109
+ out = actx.output
110
+
111
+ try:
112
+ spec = _fetch_spec(actx)
113
+ except Exception as e:
114
+ out.error(f"Failed to fetch spec: {e}")
115
+ raise typer.Exit(1) from None
116
+
117
+ routes = _extract_routes(spec)
118
+ protected = [r for r in routes if r["protected"]]
119
+ unprotected = [r for r in routes if not r["protected"]]
120
+
121
+ # Get security scheme names from components
122
+ security_schemes = list(spec.get("components", {}).get("securitySchemes", {}).keys())
123
+
124
+ out.header(f"Authentication Summary ({len(routes)} total routes)")
125
+ out.text("")
126
+ out.text(f" Protected: [green]{len(protected)}[/green]")
127
+ out.text(f" Unprotected: [yellow]{len(unprotected)}[/yellow]")
128
+ if security_schemes:
129
+ out.text(f" Schemes: {', '.join(security_schemes)}")
130
+ out.text("")
131
+
132
+ if protected:
133
+ rows = [[r["method"], r["path"], ", ".join(str(s) for s in r["security"])] for r in protected]
134
+ out.table(
135
+ title=f"Protected Endpoints ({len(protected)})",
136
+ columns=[("Method", "bold"), ("Path", ""), ("Security", "green")],
137
+ rows=rows,
138
+ data_for_json=protected,
139
+ )
140
+
141
+ if actx.json_mode:
142
+ out.raw_json({"protected": protected, "unprotected": unprotected, "schemes": security_schemes})
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # middleware
147
+ # ---------------------------------------------------------------------------
148
+ @app.command()
149
+ def middleware(
150
+ ctx: typer.Context,
151
+ path: Annotated[str, typer.Argument(help="Endpoint path to inspect (e.g. /api/v1/users).")],
152
+ ) -> None:
153
+ """Describe the middleware chain for a specific endpoint path."""
154
+ actx: AppContext = ctx.obj
155
+ out = actx.output
156
+
157
+ try:
158
+ spec = _fetch_spec(actx)
159
+ except Exception as e:
160
+ out.error(f"Failed to fetch spec: {e}")
161
+ raise typer.Exit(1) from None
162
+
163
+ paths = spec.get("paths", {})
164
+ matched = {k: v for k, v in paths.items() if path in k}
165
+
166
+ if not matched:
167
+ out.warn(f"No endpoints matching '{path}' found in spec.")
168
+ raise typer.Exit(1)
169
+
170
+ # Infer middleware from path patterns and security
171
+ chain: list[dict] = []
172
+
173
+ # CORS — always present
174
+ chain.append({"middleware": "CORS", "status": "enabled", "note": "All origins policy from config"})
175
+
176
+ # Auth middleware
177
+ for _ep_path, path_item in matched.items():
178
+ for method, operation in path_item.items():
179
+ if method not in ("get", "post", "put", "patch", "delete"):
180
+ continue
181
+ if _is_protected(operation):
182
+ chain.append({"middleware": "JWT Auth", "status": "enabled", "note": "Bearer token required"})
183
+ break
184
+
185
+ # Rate limiting — always present
186
+ chain.append(
187
+ {
188
+ "middleware": "Rate Limit",
189
+ "status": "enabled",
190
+ "note": "Tier-based: free=30, user=100, premium=300, admin=600 req/min",
191
+ }
192
+ )
193
+
194
+ # Request validation
195
+ chain.append(
196
+ {"middleware": "Request Validation", "status": "enabled", "note": "Pydantic body + query param validation"}
197
+ )
198
+
199
+ rows = [[c["middleware"], c["status"], c["note"]] for c in chain]
200
+ out.table(
201
+ title=f"Middleware Chain: {path}",
202
+ columns=[("Middleware", "bold"), ("Status", "green"), ("Note", "")],
203
+ rows=rows,
204
+ data_for_json={"path": path, "chain": chain},
205
+ )
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # unprotected
210
+ # ---------------------------------------------------------------------------
211
+ @app.command()
212
+ def unprotected(ctx: typer.Context) -> None:
213
+ """Find endpoints without authentication requirements."""
214
+ actx: AppContext = ctx.obj
215
+ out = actx.output
216
+
217
+ try:
218
+ spec = _fetch_spec(actx)
219
+ except Exception as e:
220
+ out.error(f"Failed to fetch spec: {e}")
221
+ raise typer.Exit(1) from None
222
+
223
+ routes = _extract_routes(spec)
224
+ unprotected_routes = [r for r in routes if not r["protected"]]
225
+
226
+ # Filter out common public endpoints
227
+ public_patterns = ("/health", "/openapi", "/docs", "/redoc", "/metrics")
228
+ flagged = [r for r in unprotected_routes if not any(r["path"].startswith(p) for p in public_patterns)]
229
+
230
+ if not flagged:
231
+ out.success(
232
+ f"All non-public endpoints require authentication. ({len(unprotected_routes)} public endpoints skipped)"
233
+ )
234
+ return
235
+
236
+ rows = [[r["method"], r["path"], ", ".join(r["tags"]), r["summary"][:60]] for r in flagged]
237
+ out.table(
238
+ title=f"Potentially Unprotected Endpoints ({len(flagged)})",
239
+ columns=[("Method", "bold"), ("Path", "yellow"), ("Tags", ""), ("Summary", "")],
240
+ rows=rows,
241
+ data_for_json=flagged,
242
+ )
243
+ out.warn(f"{len(flagged)} endpoints may be missing authentication.")
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # graph
248
+ # ---------------------------------------------------------------------------
249
+ @app.command()
250
+ def graph(ctx: typer.Context) -> None:
251
+ """Show route count per tag/module/router."""
252
+ actx: AppContext = ctx.obj
253
+ out = actx.output
254
+
255
+ try:
256
+ spec = _fetch_spec(actx)
257
+ except Exception as e:
258
+ out.error(f"Failed to fetch spec: {e}")
259
+ raise typer.Exit(1) from None
260
+
261
+ routes = _extract_routes(spec)
262
+
263
+ # Count by tag
264
+ tag_counts: dict[str, int] = {}
265
+ for route in routes:
266
+ for tag in route["tags"] or ["(untagged)"]:
267
+ tag_counts[tag] = tag_counts.get(tag, 0) + 1
268
+
269
+ sorted_tags = sorted(tag_counts.items(), key=lambda x: -x[1])
270
+
271
+ rows = [[tag, str(count), "#" * min(count, 40)] for tag, count in sorted_tags]
272
+ out.table(
273
+ title=f"Routes per Module ({len(routes)} total)",
274
+ columns=[("Module/Tag", "bold"), ("Count", ""), ("Bar", "green")],
275
+ rows=rows,
276
+ data_for_json=[{"tag": t, "count": c} for t, c in sorted_tags],
277
+ )
@@ -0,0 +1,145 @@
1
+ """SaaS management commands for kctl-api.
2
+
3
+ Create tenants, check status, and manage plans.
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.exceptions import APIError, AuthenticationError
14
+ from kctl_api.core.exceptions import ConnectionError as KctlConnectionError
15
+
16
+ app = typer.Typer(name="saas", help="SaaS tenant management — create, status, plans.", no_args_is_help=True)
17
+
18
+ _BASE = "/api/v1/saas"
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # create
23
+ # ---------------------------------------------------------------------------
24
+ @app.command()
25
+ def create(
26
+ ctx: typer.Context,
27
+ name: Annotated[str, typer.Option("--name", "-n", help="Tenant name.")],
28
+ plan: Annotated[str, typer.Option("--plan", "-p", help="Plan ID or slug.")] = "free",
29
+ ) -> None:
30
+ """Create a new SaaS tenant via POST /api/v1/saas/tenants."""
31
+ actx: AppContext = ctx.obj
32
+ out = actx.output
33
+
34
+ try:
35
+ result = actx.client.post(f"{_BASE}/tenants", json={"name": name, "plan": plan})
36
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
37
+ out.error(str(e))
38
+ raise typer.Exit(1) from None
39
+
40
+ out.success(f"Tenant created: {name}")
41
+ if actx.json_mode:
42
+ out.raw_json(result)
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # status
47
+ # ---------------------------------------------------------------------------
48
+ @app.command()
49
+ def status(
50
+ ctx: typer.Context,
51
+ tenant_id: Annotated[str, typer.Argument(help="Tenant ID.")],
52
+ ) -> None:
53
+ """Get tenant provisioning status via GET /api/v1/saas/tenants/{slug}/status."""
54
+ actx: AppContext = ctx.obj
55
+ out = actx.output
56
+
57
+ try:
58
+ data = actx.client.get(f"{_BASE}/tenants/{tenant_id}/status")
59
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
60
+ out.error(str(e))
61
+ raise typer.Exit(1) from None
62
+
63
+ if not data:
64
+ out.error(f"Tenant not found: {tenant_id}")
65
+ raise typer.Exit(1)
66
+
67
+ out.detail(
68
+ title=f"Tenant: {data.get('name', tenant_id)}",
69
+ sections=[
70
+ ("Details", [(k, str(v)) for k, v in data.items()]),
71
+ ],
72
+ data_for_json=data,
73
+ )
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # select-plan
78
+ # ---------------------------------------------------------------------------
79
+ @app.command(name="select-plan")
80
+ def select_plan(
81
+ ctx: typer.Context,
82
+ tenant_id: Annotated[str, typer.Argument(help="Tenant ID.")],
83
+ plan: Annotated[str, typer.Argument(help="Plan ID or slug to switch to.")],
84
+ ) -> None:
85
+ """Change tenant plan via POST /api/v1/saas/tenants/{slug}/select-plan."""
86
+ actx: AppContext = ctx.obj
87
+ out = actx.output
88
+
89
+ try:
90
+ result = actx.client.post(f"{_BASE}/tenants/{tenant_id}/select-plan", json={"plan": plan})
91
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
92
+ out.error(str(e))
93
+ raise typer.Exit(1) from None
94
+
95
+ out.success(f"Tenant {tenant_id} plan changed to {plan}.")
96
+ if actx.json_mode:
97
+ out.raw_json(result)
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # list
102
+ # ---------------------------------------------------------------------------
103
+ @app.command(name="list")
104
+ def list_tenants(
105
+ ctx: typer.Context,
106
+ page: Annotated[int, typer.Option("--page", "-p", help="Page number.")] = 1,
107
+ per_page: Annotated[int, typer.Option("--per-page", "-n", help="Items per page.")] = 20,
108
+ ) -> None:
109
+ """List SaaS tenants via GET /api/v1/saas/tenants."""
110
+ actx: AppContext = ctx.obj
111
+ out = actx.output
112
+
113
+ try:
114
+ data = actx.client.get(f"{_BASE}/tenants", params={"page": page, "per_page": per_page})
115
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
116
+ out.error(str(e))
117
+ raise typer.Exit(1) from None
118
+
119
+ items = data.get("items", []) if isinstance(data, dict) else []
120
+ total = data.get("total", len(items)) if isinstance(data, dict) else len(items)
121
+
122
+ rows: list[list[str]] = []
123
+ for t in items:
124
+ rows.append(
125
+ [
126
+ str(t.get("id", "")),
127
+ t.get("name", ""),
128
+ t.get("plan", ""),
129
+ t.get("status", ""),
130
+ str(t.get("created_at", "")),
131
+ ]
132
+ )
133
+
134
+ out.table(
135
+ title=f"SaaS Tenants (page {page}, {total} total)",
136
+ columns=[
137
+ ("ID", "bold"),
138
+ ("Name", ""),
139
+ ("Plan", ""),
140
+ ("Status", ""),
141
+ ("Created", "dim"),
142
+ ],
143
+ rows=rows,
144
+ data_for_json=items,
145
+ )