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,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.")
|