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,81 @@
|
|
|
1
|
+
"""Linting commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Run ruff and mypy checks via project scripts.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_api.core.callbacks import AppContext
|
|
13
|
+
from kctl_api.core.utils import find_project_root
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(name="lint", help="Linting — check, fix, strict.", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# check
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
@app.command()
|
|
22
|
+
def check(ctx: typer.Context) -> None:
|
|
23
|
+
"""Run ruff check + mypy via scripts/lint."""
|
|
24
|
+
actx: AppContext = ctx.obj
|
|
25
|
+
out = actx.output
|
|
26
|
+
|
|
27
|
+
root = find_project_root()
|
|
28
|
+
cmd = [str(root / "scripts" / "lint")]
|
|
29
|
+
|
|
30
|
+
out.info("Running lint checks ...")
|
|
31
|
+
result = subprocess.run(cmd, cwd=str(root), capture_output=False)
|
|
32
|
+
if result.returncode != 0:
|
|
33
|
+
out.error("Lint checks failed.")
|
|
34
|
+
raise typer.Exit(result.returncode)
|
|
35
|
+
out.success("Lint checks passed.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# fix
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
@app.command()
|
|
42
|
+
def fix(ctx: typer.Context) -> None:
|
|
43
|
+
"""Run ruff check --fix to auto-fix lint issues."""
|
|
44
|
+
actx: AppContext = ctx.obj
|
|
45
|
+
out = actx.output
|
|
46
|
+
|
|
47
|
+
root = find_project_root()
|
|
48
|
+
|
|
49
|
+
out.info("Auto-fixing lint issues ...")
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
["ruff", "check", "--fix", "."],
|
|
52
|
+
cwd=str(root),
|
|
53
|
+
capture_output=False,
|
|
54
|
+
)
|
|
55
|
+
if result.returncode != 0:
|
|
56
|
+
out.warn("Some issues could not be auto-fixed.")
|
|
57
|
+
else:
|
|
58
|
+
out.success("All auto-fixable issues resolved.")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# strict
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
@app.command()
|
|
65
|
+
def strict(ctx: typer.Context) -> None:
|
|
66
|
+
"""Run mypy in strict mode."""
|
|
67
|
+
actx: AppContext = ctx.obj
|
|
68
|
+
out = actx.output
|
|
69
|
+
|
|
70
|
+
root = find_project_root()
|
|
71
|
+
|
|
72
|
+
out.info("Running mypy strict ...")
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
["mypy", "--strict", "."],
|
|
75
|
+
cwd=str(root),
|
|
76
|
+
capture_output=False,
|
|
77
|
+
)
|
|
78
|
+
if result.returncode != 0:
|
|
79
|
+
out.error("Strict type checking failed.")
|
|
80
|
+
raise typer.Exit(result.returncode)
|
|
81
|
+
out.success("Strict type checking passed.")
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Log viewing commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Follow, search, and filter service logs via docker compose.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from kctl_api.core.callbacks import AppContext
|
|
15
|
+
from kctl_api.core.utils import find_project_root
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(name="logs", help="Log viewer — follow, errors, search, tail.", no_args_is_help=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _compose_log_cmd(args: list[str]) -> list[str]:
|
|
21
|
+
"""Build a docker compose logs command."""
|
|
22
|
+
root = find_project_root()
|
|
23
|
+
return ["docker", "compose", "-f", str(root / "docker-compose.yml"), "logs", *args]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# follow
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
@app.command()
|
|
30
|
+
def follow(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
service: Annotated[str | None, typer.Argument(help="Service name (all if omitted).")] = None,
|
|
33
|
+
tail: Annotated[int, typer.Option("--tail", "-n", help="Initial lines to show.")] = 50,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Follow service logs in real time."""
|
|
36
|
+
cmd = _compose_log_cmd(["--follow", "--tail", str(tail)])
|
|
37
|
+
if service:
|
|
38
|
+
cmd.append(service)
|
|
39
|
+
with contextlib.suppress(KeyboardInterrupt):
|
|
40
|
+
subprocess.run(cmd, capture_output=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# errors
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
@app.command()
|
|
47
|
+
def errors(
|
|
48
|
+
ctx: typer.Context,
|
|
49
|
+
service: Annotated[str | None, typer.Argument(help="Service name (all if omitted).")] = None,
|
|
50
|
+
tail: Annotated[int, typer.Option("--tail", "-n", help="Lines to scan.")] = 500,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Show only error-level log lines from services."""
|
|
53
|
+
actx: AppContext = ctx.obj
|
|
54
|
+
out = actx.output
|
|
55
|
+
|
|
56
|
+
cmd = _compose_log_cmd(["--tail", str(tail)])
|
|
57
|
+
if service:
|
|
58
|
+
cmd.append(service)
|
|
59
|
+
|
|
60
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
61
|
+
if result.returncode != 0:
|
|
62
|
+
out.error(f"Failed to fetch logs: {result.stderr.strip()}")
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
|
|
65
|
+
error_lines = [
|
|
66
|
+
line
|
|
67
|
+
for line in result.stdout.splitlines()
|
|
68
|
+
if any(kw in line.upper() for kw in ("ERROR", "CRITICAL", "FATAL", "EXCEPTION", "TRACEBACK"))
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
if not error_lines:
|
|
72
|
+
out.success("No errors found in recent logs.")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
out.header(f"Errors ({len(error_lines)} lines)")
|
|
76
|
+
for line in error_lines:
|
|
77
|
+
out.text(f"[red]{line}[/red]")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# search
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
@app.command()
|
|
84
|
+
def search(
|
|
85
|
+
ctx: typer.Context,
|
|
86
|
+
pattern: Annotated[str, typer.Argument(help="Text pattern to search for.")],
|
|
87
|
+
service: Annotated[str | None, typer.Option("--service", "-s", help="Service name.")] = None,
|
|
88
|
+
tail: Annotated[int, typer.Option("--tail", "-n", help="Lines to scan.")] = 1000,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Search service logs for a pattern."""
|
|
91
|
+
actx: AppContext = ctx.obj
|
|
92
|
+
out = actx.output
|
|
93
|
+
|
|
94
|
+
cmd = _compose_log_cmd(["--tail", str(tail)])
|
|
95
|
+
if service:
|
|
96
|
+
cmd.append(service)
|
|
97
|
+
|
|
98
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
99
|
+
if result.returncode != 0:
|
|
100
|
+
out.error(f"Failed to fetch logs: {result.stderr.strip()}")
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
matching = [line for line in result.stdout.splitlines() if pattern.lower() in line.lower()]
|
|
104
|
+
|
|
105
|
+
if not matching:
|
|
106
|
+
out.info(f"No matches for '{pattern}' in recent logs.")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
out.header(f"Search: '{pattern}' ({len(matching)} matches)")
|
|
110
|
+
for line in matching:
|
|
111
|
+
out.text(line)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# tail
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
@app.command()
|
|
118
|
+
def tail(
|
|
119
|
+
ctx: typer.Context,
|
|
120
|
+
service: Annotated[str | None, typer.Argument(help="Service name (all if omitted).")] = None,
|
|
121
|
+
lines: Annotated[int, typer.Option("--lines", "-n", help="Number of lines.")] = 100,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Show the last N lines of service logs."""
|
|
124
|
+
cmd = _compose_log_cmd(["--tail", str(lines)])
|
|
125
|
+
if service:
|
|
126
|
+
cmd.append(service)
|
|
127
|
+
subprocess.run(cmd, capture_output=False)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# structured
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
@app.command()
|
|
134
|
+
def structured(
|
|
135
|
+
ctx: typer.Context,
|
|
136
|
+
service: Annotated[str | None, typer.Argument(help="Service name (all if omitted).")] = None,
|
|
137
|
+
tail_lines: Annotated[int, typer.Option("--tail", "-n", help="Lines to scan.")] = 500,
|
|
138
|
+
level: Annotated[str | None, typer.Option("--level", "-l", help="Filter by log level.")] = None,
|
|
139
|
+
event: Annotated[str | None, typer.Option("--event", "-e", help="Filter by event name.")] = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Parse and display JSON (structured) log lines from services."""
|
|
142
|
+
actx: AppContext = ctx.obj
|
|
143
|
+
out = actx.output
|
|
144
|
+
|
|
145
|
+
cmd = _compose_log_cmd(["--tail", str(tail_lines)])
|
|
146
|
+
if service:
|
|
147
|
+
cmd.append(service)
|
|
148
|
+
|
|
149
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
150
|
+
if result.returncode != 0:
|
|
151
|
+
out.error(f"Failed to fetch logs: {result.stderr.strip()}")
|
|
152
|
+
raise typer.Exit(1)
|
|
153
|
+
|
|
154
|
+
import json
|
|
155
|
+
|
|
156
|
+
parsed: list[dict] = []
|
|
157
|
+
for line in result.stdout.splitlines():
|
|
158
|
+
# Try to extract JSON from lines (structlog may have container prefix)
|
|
159
|
+
json_start = line.find("{")
|
|
160
|
+
if json_start == -1:
|
|
161
|
+
continue
|
|
162
|
+
json_part = line[json_start:]
|
|
163
|
+
try:
|
|
164
|
+
entry = json.loads(json_part)
|
|
165
|
+
parsed.append(entry)
|
|
166
|
+
except json.JSONDecodeError:
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# Filter
|
|
170
|
+
if level:
|
|
171
|
+
parsed = [e for e in parsed if e.get("level", e.get("severity", "")).lower() == level.lower()]
|
|
172
|
+
if event:
|
|
173
|
+
parsed = [e for e in parsed if event.lower() in str(e.get("event", e.get("msg", ""))).lower()]
|
|
174
|
+
|
|
175
|
+
if not parsed:
|
|
176
|
+
out.info(f"No structured log entries found{' (with filters applied)' if level or event else ''}.")
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
rows = [
|
|
180
|
+
[
|
|
181
|
+
str(e.get("timestamp", e.get("time", ""))[:19]),
|
|
182
|
+
str(e.get("level", e.get("severity", ""))),
|
|
183
|
+
str(e.get("event", e.get("msg", ""))),
|
|
184
|
+
str(e.get("logger", e.get("name", ""))),
|
|
185
|
+
]
|
|
186
|
+
for e in parsed[-50:] # last 50
|
|
187
|
+
]
|
|
188
|
+
out.table(
|
|
189
|
+
title=f"Structured Logs ({len(parsed)} entries)",
|
|
190
|
+
columns=[("Timestamp", "dim"), ("Level", "bold"), ("Event", ""), ("Logger", "dim")],
|
|
191
|
+
rows=rows,
|
|
192
|
+
data_for_json=parsed[-100:],
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# correlation
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
@app.command()
|
|
200
|
+
def correlation(
|
|
201
|
+
ctx: typer.Context,
|
|
202
|
+
request_id: Annotated[str, typer.Argument(help="Request ID or correlation ID to trace.")],
|
|
203
|
+
service: Annotated[str | None, typer.Option("--service", "-s", help="Service name.")] = None,
|
|
204
|
+
tail_lines: Annotated[int, typer.Option("--tail", "-n", help="Lines to scan.")] = 2000,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Trace a request across services by correlation/request ID."""
|
|
207
|
+
actx: AppContext = ctx.obj
|
|
208
|
+
out = actx.output
|
|
209
|
+
|
|
210
|
+
cmd = _compose_log_cmd(["--tail", str(tail_lines)])
|
|
211
|
+
if service:
|
|
212
|
+
cmd.append(service)
|
|
213
|
+
|
|
214
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
215
|
+
if result.returncode != 0:
|
|
216
|
+
out.error(f"Failed to fetch logs: {result.stderr.strip()}")
|
|
217
|
+
raise typer.Exit(1)
|
|
218
|
+
|
|
219
|
+
import json
|
|
220
|
+
|
|
221
|
+
matching_lines: list[dict] = []
|
|
222
|
+
for line in result.stdout.splitlines():
|
|
223
|
+
if request_id not in line:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
# Try to parse as structured log
|
|
227
|
+
json_start = line.find("{")
|
|
228
|
+
if json_start >= 0:
|
|
229
|
+
try:
|
|
230
|
+
entry = json.loads(line[json_start:])
|
|
231
|
+
entry["_raw"] = line
|
|
232
|
+
matching_lines.append(entry)
|
|
233
|
+
continue
|
|
234
|
+
except json.JSONDecodeError:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
# Plain text match
|
|
238
|
+
matching_lines.append({"_raw": line, "event": line.strip()})
|
|
239
|
+
|
|
240
|
+
if not matching_lines:
|
|
241
|
+
out.info(f"No log entries found for request_id: {request_id}")
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
out.header(f"Correlation Trace: {request_id} ({len(matching_lines)} entries)")
|
|
245
|
+
for entry in matching_lines:
|
|
246
|
+
ts = entry.get("timestamp", entry.get("time", ""))[:19]
|
|
247
|
+
level = entry.get("level", entry.get("severity", "INFO"))
|
|
248
|
+
event = entry.get("event", entry.get("msg", entry.get("_raw", "")))
|
|
249
|
+
logger = entry.get("logger", entry.get("name", ""))
|
|
250
|
+
|
|
251
|
+
color = {"ERROR": "red", "WARNING": "yellow", "WARN": "yellow", "DEBUG": "dim"}.get(level.upper(), "")
|
|
252
|
+
if color:
|
|
253
|
+
out.text(f" [{ts}] [{color}]{level}[/{color}] {logger}: {event}")
|
|
254
|
+
else:
|
|
255
|
+
out.text(f" [{ts}] {level} {logger}: {event}")
|
|
256
|
+
|
|
257
|
+
if actx.json_mode:
|
|
258
|
+
out.raw_json({"request_id": request_id, "entries": matching_lines})
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Marketplace commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Browse, search, install, and manage extensions.
|
|
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="marketplace", help="Extension marketplace — browse, install, publish.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
_BASE = "/api/v1/marketplace/extensions"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# list
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
@app.command(name="list")
|
|
25
|
+
def list_extensions(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
page: Annotated[int, typer.Option("--page", "-p", help="Page number.")] = 1,
|
|
28
|
+
per_page: Annotated[int, typer.Option("--per-page", "-n", help="Items per page.")] = 20,
|
|
29
|
+
category: Annotated[str | None, typer.Option("--category", "-c", help="Filter by category.")] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""List marketplace extensions."""
|
|
32
|
+
actx: AppContext = ctx.obj
|
|
33
|
+
out = actx.output
|
|
34
|
+
|
|
35
|
+
params: dict[str, str | int] = {"page": page, "per_page": per_page}
|
|
36
|
+
if category:
|
|
37
|
+
params["category"] = category
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
data = actx.client.get(_BASE, params=params)
|
|
41
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
42
|
+
out.error(str(e))
|
|
43
|
+
raise typer.Exit(1) from None
|
|
44
|
+
|
|
45
|
+
items = data.get("items", []) if isinstance(data, dict) else []
|
|
46
|
+
total = data.get("total", len(items)) if isinstance(data, dict) else len(items)
|
|
47
|
+
|
|
48
|
+
rows: list[list[str]] = []
|
|
49
|
+
for ext in items:
|
|
50
|
+
rows.append(
|
|
51
|
+
[
|
|
52
|
+
str(ext.get("id", "")),
|
|
53
|
+
ext.get("name", ""),
|
|
54
|
+
ext.get("version", ""),
|
|
55
|
+
ext.get("author", ""),
|
|
56
|
+
ext.get("category", ""),
|
|
57
|
+
"[green]yes[/green]" if ext.get("installed") else "[dim]no[/dim]",
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
out.table(
|
|
62
|
+
title=f"Marketplace Extensions (page {page}, {total} total)",
|
|
63
|
+
columns=[
|
|
64
|
+
("ID", "bold"),
|
|
65
|
+
("Name", ""),
|
|
66
|
+
("Version", ""),
|
|
67
|
+
("Author", "dim"),
|
|
68
|
+
("Category", ""),
|
|
69
|
+
("Installed", ""),
|
|
70
|
+
],
|
|
71
|
+
rows=rows,
|
|
72
|
+
data_for_json=items,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# search
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
@app.command()
|
|
80
|
+
def search(
|
|
81
|
+
ctx: typer.Context,
|
|
82
|
+
query: Annotated[str, typer.Argument(help="Search query.")],
|
|
83
|
+
page: Annotated[int, typer.Option("--page", "-p", help="Page number.")] = 1,
|
|
84
|
+
per_page: Annotated[int, typer.Option("--per-page", "-n", help="Items per page.")] = 20,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Search marketplace extensions by name or description."""
|
|
87
|
+
actx: AppContext = ctx.obj
|
|
88
|
+
out = actx.output
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
data = actx.client.get(_BASE, params={"search": query, "page": page, "per_page": per_page})
|
|
92
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
93
|
+
out.error(str(e))
|
|
94
|
+
raise typer.Exit(1) from None
|
|
95
|
+
|
|
96
|
+
items = data.get("items", []) if isinstance(data, dict) else []
|
|
97
|
+
total = data.get("total", len(items)) if isinstance(data, dict) else len(items)
|
|
98
|
+
|
|
99
|
+
rows: list[list[str]] = []
|
|
100
|
+
for ext in items:
|
|
101
|
+
rows.append(
|
|
102
|
+
[
|
|
103
|
+
str(ext.get("id", "")),
|
|
104
|
+
ext.get("name", ""),
|
|
105
|
+
ext.get("version", ""),
|
|
106
|
+
ext.get("description", "")[:60],
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
out.table(
|
|
111
|
+
title=f"Search: '{query}' ({total} results)",
|
|
112
|
+
columns=[
|
|
113
|
+
("ID", "bold"),
|
|
114
|
+
("Name", ""),
|
|
115
|
+
("Version", ""),
|
|
116
|
+
("Description", "dim"),
|
|
117
|
+
],
|
|
118
|
+
rows=rows,
|
|
119
|
+
data_for_json=items,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# get
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
@app.command(name="get")
|
|
127
|
+
def get_extension(
|
|
128
|
+
ctx: typer.Context,
|
|
129
|
+
extension_id: Annotated[str, typer.Argument(help="Extension ID.")],
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Get extension details."""
|
|
132
|
+
actx: AppContext = ctx.obj
|
|
133
|
+
out = actx.output
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
data = actx.client.get(f"{_BASE}/{extension_id}")
|
|
137
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
138
|
+
out.error(str(e))
|
|
139
|
+
raise typer.Exit(1) from None
|
|
140
|
+
|
|
141
|
+
if not data:
|
|
142
|
+
out.error(f"Extension not found: {extension_id}")
|
|
143
|
+
raise typer.Exit(1)
|
|
144
|
+
|
|
145
|
+
out.detail(
|
|
146
|
+
title=f"Extension: {data.get('name', extension_id)}",
|
|
147
|
+
sections=[
|
|
148
|
+
("Details", [(k, str(v)) for k, v in data.items()]),
|
|
149
|
+
],
|
|
150
|
+
data_for_json=data,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# install
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
@app.command()
|
|
158
|
+
def install(
|
|
159
|
+
ctx: typer.Context,
|
|
160
|
+
extension_id: Annotated[str, typer.Argument(help="Extension ID to install.")],
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Install a marketplace extension via POST."""
|
|
163
|
+
actx: AppContext = ctx.obj
|
|
164
|
+
out = actx.output
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
result = actx.client.post(f"{_BASE}/{extension_id}/install")
|
|
168
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
169
|
+
out.error(str(e))
|
|
170
|
+
raise typer.Exit(1) from None
|
|
171
|
+
|
|
172
|
+
out.success(f"Extension {extension_id} installed.")
|
|
173
|
+
if actx.json_mode:
|
|
174
|
+
out.raw_json(result)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# uninstall
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
@app.command()
|
|
181
|
+
def uninstall(
|
|
182
|
+
ctx: typer.Context,
|
|
183
|
+
extension_id: Annotated[str, typer.Argument(help="Extension ID to uninstall.")],
|
|
184
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Uninstall a marketplace extension via DELETE."""
|
|
187
|
+
actx: AppContext = ctx.obj
|
|
188
|
+
out = actx.output
|
|
189
|
+
|
|
190
|
+
if not force:
|
|
191
|
+
confirm = typer.confirm(f"Uninstall extension {extension_id}?", default=False)
|
|
192
|
+
if not confirm:
|
|
193
|
+
out.info("Cancelled.")
|
|
194
|
+
raise typer.Exit(0)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
result = actx.client.delete(f"{_BASE}/{extension_id}/install")
|
|
198
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
199
|
+
out.error(str(e))
|
|
200
|
+
raise typer.Exit(1) from None
|
|
201
|
+
|
|
202
|
+
out.success(f"Extension {extension_id} uninstalled.")
|
|
203
|
+
if actx.json_mode:
|
|
204
|
+
out.raw_json(result)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# publish
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
@app.command()
|
|
211
|
+
def publish(
|
|
212
|
+
ctx: typer.Context,
|
|
213
|
+
manifest_path: Annotated[str, typer.Argument(help="Path to extension manifest JSON.")],
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Publish a new extension via POST /api/v1/marketplace/extensions."""
|
|
216
|
+
actx: AppContext = ctx.obj
|
|
217
|
+
out = actx.output
|
|
218
|
+
|
|
219
|
+
import json
|
|
220
|
+
from pathlib import Path
|
|
221
|
+
|
|
222
|
+
manifest_file = Path(manifest_path)
|
|
223
|
+
if not manifest_file.exists():
|
|
224
|
+
out.error(f"Manifest file not found: {manifest_path}")
|
|
225
|
+
raise typer.Exit(1)
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
payload = json.loads(manifest_file.read_text())
|
|
229
|
+
except json.JSONDecodeError as e:
|
|
230
|
+
out.error(f"Invalid JSON manifest: {e}")
|
|
231
|
+
raise typer.Exit(1) from None
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
result = actx.client.post(_BASE, json=payload)
|
|
235
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
236
|
+
out.error(str(e))
|
|
237
|
+
raise typer.Exit(1) from None
|
|
238
|
+
|
|
239
|
+
out.success(f"Extension published: {payload.get('name', 'unknown')}")
|
|
240
|
+
if actx.json_mode:
|
|
241
|
+
out.raw_json(result)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# review
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
@app.command()
|
|
248
|
+
def review(
|
|
249
|
+
ctx: typer.Context,
|
|
250
|
+
extension_id: Annotated[str, typer.Argument(help="Extension ID to review.")],
|
|
251
|
+
rating: Annotated[int, typer.Option("--rating", "-r", help="Rating (1-5).")],
|
|
252
|
+
comment: Annotated[str | None, typer.Option("--comment", "-c", help="Review comment.")] = None,
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Submit a review for an extension via POST."""
|
|
255
|
+
actx: AppContext = ctx.obj
|
|
256
|
+
out = actx.output
|
|
257
|
+
|
|
258
|
+
if rating < 1 or rating > 5:
|
|
259
|
+
out.error("Rating must be between 1 and 5.")
|
|
260
|
+
raise typer.Exit(1)
|
|
261
|
+
|
|
262
|
+
payload: dict = {"rating": rating}
|
|
263
|
+
if comment:
|
|
264
|
+
payload["comment"] = comment
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
result = actx.client.post(f"{_BASE}/{extension_id}/reviews", json=payload)
|
|
268
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
269
|
+
out.error(str(e))
|
|
270
|
+
raise typer.Exit(1) from None
|
|
271
|
+
|
|
272
|
+
out.success(f"Review submitted for extension {extension_id}.")
|
|
273
|
+
if actx.json_mode:
|
|
274
|
+
out.raw_json(result)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# versions
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
@app.command()
|
|
281
|
+
def versions(
|
|
282
|
+
ctx: typer.Context,
|
|
283
|
+
extension_id: Annotated[str, typer.Argument(help="Extension ID.")],
|
|
284
|
+
) -> None:
|
|
285
|
+
"""List available versions for an extension via GET."""
|
|
286
|
+
actx: AppContext = ctx.obj
|
|
287
|
+
out = actx.output
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
data = actx.client.get(f"{_BASE}/{extension_id}/versions")
|
|
291
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
292
|
+
out.error(str(e))
|
|
293
|
+
raise typer.Exit(1) from None
|
|
294
|
+
|
|
295
|
+
items = data if isinstance(data, list) else data.get("items", []) if isinstance(data, dict) else []
|
|
296
|
+
|
|
297
|
+
rows: list[list[str]] = []
|
|
298
|
+
for v in items:
|
|
299
|
+
rows.append(
|
|
300
|
+
[
|
|
301
|
+
v.get("version", ""),
|
|
302
|
+
str(v.get("released_at", "")),
|
|
303
|
+
v.get("changelog", "")[:60],
|
|
304
|
+
]
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
out.table(
|
|
308
|
+
title=f"Versions: Extension {extension_id}",
|
|
309
|
+
columns=[
|
|
310
|
+
("Version", "bold"),
|
|
311
|
+
("Released", "dim"),
|
|
312
|
+
("Changelog", ""),
|
|
313
|
+
],
|
|
314
|
+
rows=rows,
|
|
315
|
+
data_for_json=items,
|
|
316
|
+
)
|