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,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
+ )