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,169 @@
1
+ """Tenant AI commands for kctl-api.
2
+
3
+ Per-tenant AI chat, streaming, history, and usage tracking.
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="tenant-ai", help="Tenant AI — chat, stream, history, usage.", no_args_is_help=True)
17
+
18
+ _BASE = "/api/v1/tenant-ai"
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # chat
23
+ # ---------------------------------------------------------------------------
24
+ @app.command()
25
+ def chat(
26
+ ctx: typer.Context,
27
+ message: Annotated[str, typer.Argument(help="Message to send.")],
28
+ tenant_id: Annotated[str | None, typer.Option("--tenant", "-t", help="Tenant ID.")] = None,
29
+ ) -> None:
30
+ """Send a chat message to the tenant AI via POST /api/v1/tenant-ai/chat."""
31
+ actx: AppContext = ctx.obj
32
+ out = actx.output
33
+
34
+ payload: dict = {"message": message}
35
+ if tenant_id:
36
+ payload["tenant_id"] = tenant_id
37
+
38
+ try:
39
+ result = actx.client.post(f"{_BASE}/chat", json=payload)
40
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
41
+ out.error(str(e))
42
+ raise typer.Exit(1) from None
43
+
44
+ if actx.json_mode:
45
+ out.raw_json(result)
46
+ else:
47
+ reply = result.get("reply", result.get("message", "")) if isinstance(result, dict) else str(result)
48
+ out.text(reply)
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # stream
53
+ # ---------------------------------------------------------------------------
54
+ @app.command()
55
+ def stream(
56
+ ctx: typer.Context,
57
+ message: Annotated[str, typer.Argument(help="Message to send.")],
58
+ tenant_id: Annotated[str | None, typer.Option("--tenant", "-t", help="Tenant ID.")] = None,
59
+ ) -> None:
60
+ """Stream a chat response from the tenant AI via POST /api/v1/tenant-ai/chat/stream.
61
+
62
+ Note: Currently sends a blocking request. True SSE streaming will be added in a future version.
63
+ """
64
+ actx: AppContext = ctx.obj
65
+ out = actx.output
66
+
67
+ payload: dict = {"message": message}
68
+ if tenant_id:
69
+ payload["tenant_id"] = tenant_id
70
+
71
+ try:
72
+ result = actx.client.post(f"{_BASE}/chat/stream", json=payload)
73
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
74
+ out.error(str(e))
75
+ raise typer.Exit(1) from None
76
+
77
+ if actx.json_mode:
78
+ out.raw_json(result)
79
+ else:
80
+ reply = result.get("reply", result.get("message", "")) if isinstance(result, dict) else str(result)
81
+ out.text(reply)
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # history
86
+ # ---------------------------------------------------------------------------
87
+ @app.command()
88
+ def history(
89
+ ctx: typer.Context,
90
+ tenant_id: Annotated[str | None, typer.Option("--tenant", "-t", help="Tenant ID.")] = None,
91
+ page: Annotated[int, typer.Option("--page", "-p", help="Page number.")] = 1,
92
+ per_page: Annotated[int, typer.Option("--per-page", "-n", help="Items per page.")] = 20,
93
+ ) -> None:
94
+ """List tenant AI conversation history via GET /api/v1/tenant-ai/history."""
95
+ actx: AppContext = ctx.obj
96
+ out = actx.output
97
+
98
+ params: dict[str, str | int] = {"page": page, "per_page": per_page}
99
+ if tenant_id:
100
+ params["tenant_id"] = tenant_id
101
+
102
+ try:
103
+ data = actx.client.get(f"{_BASE}/history", params=params)
104
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
105
+ out.error(str(e))
106
+ raise typer.Exit(1) from None
107
+
108
+ items = data.get("items", []) if isinstance(data, dict) else []
109
+ total = data.get("total", len(items)) if isinstance(data, dict) else len(items)
110
+
111
+ rows: list[list[str]] = []
112
+ for h in items:
113
+ rows.append(
114
+ [
115
+ str(h.get("id", "")),
116
+ h.get("tenant_id", ""),
117
+ str(h.get("message_count", "")),
118
+ str(h.get("tokens_used", "")),
119
+ str(h.get("created_at", "")),
120
+ ]
121
+ )
122
+
123
+ out.table(
124
+ title=f"Tenant AI History (page {page}, {total} total)",
125
+ columns=[
126
+ ("ID", "bold"),
127
+ ("Tenant", ""),
128
+ ("Messages", ""),
129
+ ("Tokens", ""),
130
+ ("Created", "dim"),
131
+ ],
132
+ rows=rows,
133
+ data_for_json=items,
134
+ )
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # usage
139
+ # ---------------------------------------------------------------------------
140
+ @app.command()
141
+ def usage(
142
+ ctx: typer.Context,
143
+ tenant_id: Annotated[str | None, typer.Option("--tenant", "-t", help="Tenant ID.")] = None,
144
+ ) -> None:
145
+ """Get tenant AI usage statistics via GET /api/v1/tenant-ai/usage."""
146
+ actx: AppContext = ctx.obj
147
+ out = actx.output
148
+
149
+ params: dict[str, str] = {}
150
+ if tenant_id:
151
+ params["tenant_id"] = tenant_id
152
+
153
+ try:
154
+ data = actx.client.get(f"{_BASE}/usage", params=params)
155
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
156
+ out.error(str(e))
157
+ raise typer.Exit(1) from None
158
+
159
+ if not data:
160
+ out.info("No usage data available.")
161
+ return
162
+
163
+ out.detail(
164
+ title="Tenant AI Usage",
165
+ sections=[
166
+ ("Usage", [(k, str(v)) for k, v in data.items()]),
167
+ ],
168
+ data_for_json=data,
169
+ )
@@ -0,0 +1,95 @@
1
+ """Test runner commands for kctl-api.
2
+
3
+ Run pytest for specific apps or the entire workspace.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from kctl_api.core.callbacks import AppContext
14
+ from kctl_api.core.utils import find_project_root
15
+
16
+ app = typer.Typer(name="test", help="Test runner — run, coverage, watch.", no_args_is_help=True)
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # run
21
+ # ---------------------------------------------------------------------------
22
+ @app.command()
23
+ def run(
24
+ ctx: typer.Context,
25
+ app_name: Annotated[str, typer.Argument(help="App name or 'all'.")] = "all",
26
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Verbose output.")] = False,
27
+ marker: Annotated[str | None, typer.Option("--marker", "-m", help="Pytest marker expression.")] = None,
28
+ keyword: Annotated[str | None, typer.Option("--keyword", "-k", help="Pytest keyword expression.")] = None,
29
+ ) -> None:
30
+ """Run tests via scripts/test."""
31
+ actx: AppContext = ctx.obj
32
+ out = actx.output
33
+
34
+ root = find_project_root()
35
+ cmd = [str(root / "scripts" / "test"), app_name]
36
+ if verbose:
37
+ cmd.append("-v")
38
+ if marker:
39
+ cmd.extend(["-m", marker])
40
+ if keyword:
41
+ cmd.extend(["-k", keyword])
42
+
43
+ out.info(f"Running tests for: {app_name}")
44
+ result = subprocess.run(cmd, cwd=str(root), capture_output=False)
45
+ if result.returncode != 0:
46
+ out.error("Tests failed.")
47
+ raise typer.Exit(result.returncode)
48
+ out.success("Tests passed.")
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # coverage
53
+ # ---------------------------------------------------------------------------
54
+ @app.command()
55
+ def coverage(
56
+ ctx: typer.Context,
57
+ app_name: Annotated[str, typer.Argument(help="App name or 'all'.")] = "all",
58
+ ) -> None:
59
+ """Run tests with coverage report via scripts/test --cov."""
60
+ actx: AppContext = ctx.obj
61
+ out = actx.output
62
+
63
+ root = find_project_root()
64
+ cmd = [str(root / "scripts" / "test"), app_name, "--cov"]
65
+
66
+ out.info(f"Running tests with coverage for: {app_name}")
67
+ result = subprocess.run(cmd, cwd=str(root), capture_output=False)
68
+ if result.returncode != 0:
69
+ out.error("Tests failed.")
70
+ raise typer.Exit(result.returncode)
71
+ out.success("Coverage report generated.")
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # watch
76
+ # ---------------------------------------------------------------------------
77
+ @app.command()
78
+ def watch(
79
+ ctx: typer.Context,
80
+ app_name: Annotated[str, typer.Argument(help="App name or 'all'.")] = "all",
81
+ ) -> None:
82
+ """Run tests in watch mode (requires pytest-watch)."""
83
+ actx: AppContext = ctx.obj
84
+ out = actx.output
85
+
86
+ root = find_project_root()
87
+
88
+ out.info(f"Starting test watcher for: {app_name}")
89
+ app_dir = root / "apps" / app_name if app_name != "all" else root
90
+ cmd = ["ptw", "--", str(app_dir)]
91
+ try:
92
+ subprocess.run(cmd, cwd=str(root), capture_output=False)
93
+ except FileNotFoundError:
94
+ out.error("pytest-watch (ptw) not found. Install with: uv pip install pytest-watch")
95
+ raise typer.Exit(1) from None
@@ -0,0 +1,302 @@
1
+ """User management commands for kctl-api.
2
+
3
+ CRUD operations on users, role and tier assignment.
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
+ from kctl_api.core.resolve import resolve_user, resolve_user_id
16
+ from kctl_api.core.utils import role_color, tier_color
17
+
18
+ app = typer.Typer(
19
+ name="users", help="User management — list, create, update, delete, roles, tiers.", no_args_is_help=True
20
+ )
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # list
25
+ # ---------------------------------------------------------------------------
26
+ @app.command(name="list")
27
+ def list_users(
28
+ ctx: typer.Context,
29
+ page: Annotated[int, typer.Option("--page", "-p", help="Page number.")] = 1,
30
+ per_page: Annotated[int, typer.Option("--per-page", "-n", help="Items per page.")] = 20,
31
+ search: Annotated[str | None, typer.Option("--search", "-s", help="Search by name or email.")] = None,
32
+ ) -> None:
33
+ """List users with pagination and optional search."""
34
+ actx: AppContext = ctx.obj
35
+ out = actx.output
36
+
37
+ try:
38
+ params: dict[str, str | int] = {"page": page, "per_page": per_page}
39
+ if search:
40
+ params["search"] = search
41
+ data = actx.client.get("/api/v1/users", params=params)
42
+ except AuthenticationError as e:
43
+ out.error(f"Auth failed: {e}")
44
+ raise typer.Exit(1) from None
45
+ except KctlConnectionError as e:
46
+ out.error(f"Connection failed: {e}")
47
+ raise typer.Exit(1) from None
48
+ except APIError as e:
49
+ out.error(f"API error: {e.detail}")
50
+ raise typer.Exit(1) from None
51
+
52
+ items = data.get("items", []) if isinstance(data, dict) else []
53
+ total = data.get("total", len(items)) if isinstance(data, dict) else len(items)
54
+
55
+ rows: list[list[str]] = []
56
+ for u in items:
57
+ rows.append(
58
+ [
59
+ str(u.get("id", "")),
60
+ u.get("email", ""),
61
+ u.get("name", u.get("full_name", "")),
62
+ role_color(u.get("role", "")),
63
+ tier_color(u.get("tier", "")),
64
+ "[green]yes[/green]" if u.get("is_active") else "[red]no[/red]",
65
+ ]
66
+ )
67
+
68
+ out.table(
69
+ title=f"Users (page {page}, {total} total)",
70
+ columns=[
71
+ ("ID", "bold"),
72
+ ("Email", ""),
73
+ ("Name", ""),
74
+ ("Role", ""),
75
+ ("Tier", ""),
76
+ ("Active", ""),
77
+ ],
78
+ rows=rows,
79
+ data_for_json=items,
80
+ )
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # get
85
+ # ---------------------------------------------------------------------------
86
+ @app.command()
87
+ def get(
88
+ ctx: typer.Context,
89
+ identifier: Annotated[str, typer.Argument(help="User ID or email.")],
90
+ ) -> None:
91
+ """Get detailed user information by ID or email."""
92
+ actx: AppContext = ctx.obj
93
+ out = actx.output
94
+
95
+ try:
96
+ user = resolve_user(actx.client, identifier)
97
+ except typer.Exit:
98
+ raise
99
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
100
+ out.error(str(e))
101
+ raise typer.Exit(1) from None
102
+
103
+ role = user.get("role", "unknown")
104
+ tier = user.get("tier", "unknown")
105
+
106
+ out.detail(
107
+ title=f"User: {user.get('email', identifier)}",
108
+ sections=[
109
+ (
110
+ "Identity",
111
+ [
112
+ ("ID", str(user.get("id", ""))),
113
+ ("Email", user.get("email", "")),
114
+ ("Name", user.get("name", user.get("full_name", ""))),
115
+ ],
116
+ ),
117
+ (
118
+ "Access",
119
+ [
120
+ ("Role", role_color(role)),
121
+ ("Tier", tier_color(tier)),
122
+ ("Active", str(user.get("is_active", ""))),
123
+ ],
124
+ ),
125
+ (
126
+ "Timestamps",
127
+ [
128
+ ("Created", str(user.get("created_at", ""))),
129
+ ("Updated", str(user.get("updated_at", ""))),
130
+ ],
131
+ ),
132
+ ],
133
+ data_for_json=user,
134
+ )
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # create
139
+ # ---------------------------------------------------------------------------
140
+ @app.command()
141
+ def create(
142
+ ctx: typer.Context,
143
+ email: Annotated[str, typer.Option("--email", "-e", help="User email.")],
144
+ name: Annotated[str, typer.Option("--name", "-n", help="User full name.")],
145
+ password: Annotated[
146
+ str | None, typer.Option("--password", help="Password (prompted if omitted).", hide_input=True)
147
+ ] = None,
148
+ ) -> None:
149
+ """Create a new user via POST /api/v1/auth/register."""
150
+ actx: AppContext = ctx.obj
151
+ out = actx.output
152
+
153
+ if not password:
154
+ password = typer.prompt("Password", hide_input=True)
155
+
156
+ try:
157
+ result = actx.client.post(
158
+ "/api/v1/auth/register",
159
+ json={"email": email, "name": name, "password": password},
160
+ )
161
+ except AuthenticationError as e:
162
+ out.error(f"Auth failed: {e}")
163
+ raise typer.Exit(1) from None
164
+ except KctlConnectionError as e:
165
+ out.error(f"Connection failed: {e}")
166
+ raise typer.Exit(1) from None
167
+ except APIError as e:
168
+ out.error(f"API error: {e.detail}")
169
+ raise typer.Exit(1) from None
170
+
171
+ out.success(f"User created: {email}")
172
+ if actx.json_mode:
173
+ out.raw_json(result)
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # update
178
+ # ---------------------------------------------------------------------------
179
+ @app.command()
180
+ def update(
181
+ ctx: typer.Context,
182
+ identifier: Annotated[str, typer.Argument(help="User ID or email.")],
183
+ name: Annotated[str | None, typer.Option("--name", "-n", help="New name.")] = None,
184
+ email: Annotated[str | None, typer.Option("--email", "-e", help="New email.")] = None,
185
+ ) -> None:
186
+ """Update user fields via PATCH /api/v1/users/{id}."""
187
+ actx: AppContext = ctx.obj
188
+ out = actx.output
189
+
190
+ user_id = resolve_user_id(actx.client, identifier)
191
+ payload: dict[str, str] = {}
192
+ if name:
193
+ payload["name"] = name
194
+ if email:
195
+ payload["email"] = email
196
+
197
+ if not payload:
198
+ out.warn("No fields to update. Use --name or --email.")
199
+ raise typer.Exit(0)
200
+
201
+ try:
202
+ result = actx.client.patch(f"/api/v1/users/{user_id}", json=payload)
203
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
204
+ out.error(str(e))
205
+ raise typer.Exit(1) from None
206
+
207
+ out.success(f"User {user_id} updated.")
208
+ if actx.json_mode:
209
+ out.raw_json(result)
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # delete
214
+ # ---------------------------------------------------------------------------
215
+ @app.command()
216
+ def delete(
217
+ ctx: typer.Context,
218
+ identifier: Annotated[str, typer.Argument(help="User ID or email.")],
219
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
220
+ ) -> None:
221
+ """Soft-delete a user via DELETE /api/v1/users/{id}."""
222
+ actx: AppContext = ctx.obj
223
+ out = actx.output
224
+
225
+ user_id = resolve_user_id(actx.client, identifier)
226
+
227
+ if not force:
228
+ confirm = typer.confirm(f"Delete user {identifier} (id={user_id})?", default=False)
229
+ if not confirm:
230
+ out.info("Cancelled.")
231
+ raise typer.Exit(0)
232
+
233
+ try:
234
+ result = actx.client.delete(f"/api/v1/users/{user_id}")
235
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
236
+ out.error(str(e))
237
+ raise typer.Exit(1) from None
238
+
239
+ out.success(f"User {user_id} deleted.")
240
+ if actx.json_mode:
241
+ out.raw_json(result)
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # set-role
246
+ # ---------------------------------------------------------------------------
247
+ @app.command(name="set-role")
248
+ def set_role(
249
+ ctx: typer.Context,
250
+ identifier: Annotated[str, typer.Argument(help="User ID or email.")],
251
+ role: Annotated[str, typer.Argument(help="Role: user or admin.")],
252
+ ) -> None:
253
+ """Set user role via PATCH /api/v1/users/{id}."""
254
+ actx: AppContext = ctx.obj
255
+ out = actx.output
256
+
257
+ if role not in ("user", "admin"):
258
+ out.error(f"Invalid role '{role}'. Must be 'user' or 'admin'.")
259
+ raise typer.Exit(1)
260
+
261
+ user_id = resolve_user_id(actx.client, identifier)
262
+
263
+ try:
264
+ result = actx.client.patch(f"/api/v1/users/{user_id}", json={"role": role})
265
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
266
+ out.error(str(e))
267
+ raise typer.Exit(1) from None
268
+
269
+ out.success(f"User {user_id} role set to {role}.")
270
+ if actx.json_mode:
271
+ out.raw_json(result)
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # set-tier
276
+ # ---------------------------------------------------------------------------
277
+ @app.command(name="set-tier")
278
+ def set_tier(
279
+ ctx: typer.Context,
280
+ identifier: Annotated[str, typer.Argument(help="User ID or email.")],
281
+ tier: Annotated[str, typer.Argument(help="Tier: free, user, premium, admin.")],
282
+ ) -> None:
283
+ """Set user tier via PATCH /api/v1/users/{id}."""
284
+ actx: AppContext = ctx.obj
285
+ out = actx.output
286
+
287
+ valid_tiers = ("free", "user", "premium", "admin")
288
+ if tier not in valid_tiers:
289
+ out.error(f"Invalid tier '{tier}'. Must be one of: {', '.join(valid_tiers)}")
290
+ raise typer.Exit(1)
291
+
292
+ user_id = resolve_user_id(actx.client, identifier)
293
+
294
+ try:
295
+ result = actx.client.patch(f"/api/v1/users/{user_id}", json={"tier": tier})
296
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
297
+ out.error(str(e))
298
+ raise typer.Exit(1) from None
299
+
300
+ out.success(f"User {user_id} tier set to {tier}.")
301
+ if actx.json_mode:
302
+ out.raw_json(result)
@@ -0,0 +1,56 @@
1
+ """Webhook management commands for kctl-api.
2
+
3
+ View recent webhooks, replay, and verify signatures.
4
+ Note: These commands require webhook audit endpoints on api-main (not yet implemented).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from kctl_api.core.callbacks import AppContext
14
+
15
+ app = typer.Typer(name="webhooks", help="Webhook management — recent, replay, verify.", no_args_is_help=True)
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # recent
20
+ # ---------------------------------------------------------------------------
21
+ @app.command()
22
+ def recent(
23
+ ctx: typer.Context,
24
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Number of recent webhooks.")] = 20,
25
+ source: Annotated[str | None, typer.Option("--source", "-s", help="Filter by source (github, chatwoot).")] = None,
26
+ ) -> None:
27
+ """List recent webhook deliveries (requires webhook audit API — not yet available)."""
28
+ actx: AppContext = ctx.obj
29
+ actx.output.warn("Webhook audit endpoints are not yet implemented on api-main.")
30
+ actx.output.info("Available webhook endpoints: POST /webhooks/mattermost, /telegram, /generic/{source}")
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # replay
35
+ # ---------------------------------------------------------------------------
36
+ @app.command()
37
+ def replay(
38
+ ctx: typer.Context,
39
+ webhook_id: Annotated[str, typer.Argument(help="Webhook delivery ID to replay.")],
40
+ ) -> None:
41
+ """Replay a webhook delivery (requires webhook audit API — not yet available)."""
42
+ actx: AppContext = ctx.obj
43
+ actx.output.warn("Webhook replay is not yet implemented on api-main.")
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # verify
48
+ # ---------------------------------------------------------------------------
49
+ @app.command()
50
+ def verify(
51
+ ctx: typer.Context,
52
+ source: Annotated[str, typer.Argument(help="Webhook source to verify (github, chatwoot).")],
53
+ ) -> None:
54
+ """Verify webhook signature configuration (requires webhook audit API — not yet available)."""
55
+ actx: AppContext = ctx.obj
56
+ actx.output.warn("Webhook verification is not yet implemented on api-main.")