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,197 @@
|
|
|
1
|
+
"""Interactive shell command for kctl-api.
|
|
2
|
+
|
|
3
|
+
Start a Python REPL with pre-loaded context.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from kctl_api.core.callbacks import AppContext
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(name="shell", help="Interactive Python REPL with API context.", no_args_is_help=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# start
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
@app.command()
|
|
19
|
+
def start(ctx: typer.Context) -> None:
|
|
20
|
+
"""Start an interactive Python REPL with pre-loaded API client and helpers."""
|
|
21
|
+
actx: AppContext = ctx.obj
|
|
22
|
+
out = actx.output
|
|
23
|
+
|
|
24
|
+
out.info("Starting interactive shell ...")
|
|
25
|
+
out.info("Available objects: client, ai_client, actx, out")
|
|
26
|
+
|
|
27
|
+
namespace = {
|
|
28
|
+
"actx": actx,
|
|
29
|
+
"client": actx.client,
|
|
30
|
+
"out": actx.output,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Try ai_client lazily to avoid connection errors at startup
|
|
34
|
+
try:
|
|
35
|
+
namespace["ai_client"] = actx.ai_client
|
|
36
|
+
except Exception:
|
|
37
|
+
namespace["ai_client"] = None
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
import IPython
|
|
41
|
+
|
|
42
|
+
IPython.start_ipython(argv=[], user_ns=namespace)
|
|
43
|
+
except ImportError:
|
|
44
|
+
import code
|
|
45
|
+
|
|
46
|
+
code.interact(
|
|
47
|
+
banner="kctl-api shell (use 'client', 'ai_client', 'actx', 'out')",
|
|
48
|
+
local=namespace,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# db
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
@app.command()
|
|
56
|
+
def db(ctx: typer.Context) -> None:
|
|
57
|
+
"""Start a Python REPL with an async SQLAlchemy DB session pre-loaded."""
|
|
58
|
+
actx: AppContext = ctx.obj
|
|
59
|
+
out = actx.output
|
|
60
|
+
|
|
61
|
+
db_url = actx.database_url
|
|
62
|
+
if not db_url:
|
|
63
|
+
out.error("No database_url configured.")
|
|
64
|
+
raise typer.Exit(1)
|
|
65
|
+
|
|
66
|
+
out.info("Starting DB shell ...")
|
|
67
|
+
out.info("Available: db (AsyncSession), execute_query, dispose_engine")
|
|
68
|
+
|
|
69
|
+
async def _get_session(): # type: ignore[return]
|
|
70
|
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
71
|
+
from sqlalchemy.orm import sessionmaker
|
|
72
|
+
|
|
73
|
+
engine = create_async_engine(db_url)
|
|
74
|
+
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) # type: ignore[call-overload]
|
|
75
|
+
return async_session()
|
|
76
|
+
|
|
77
|
+
import asyncio
|
|
78
|
+
|
|
79
|
+
session = asyncio.run(_get_session())
|
|
80
|
+
|
|
81
|
+
namespace = {
|
|
82
|
+
"actx": actx,
|
|
83
|
+
"out": out,
|
|
84
|
+
"session": session,
|
|
85
|
+
"db": session,
|
|
86
|
+
"asyncio": asyncio,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
banner = (
|
|
90
|
+
"kctl-api db shell\n"
|
|
91
|
+
"Available: session (AsyncSession), db (alias), asyncio\n"
|
|
92
|
+
"Example: asyncio.run(session.execute(text('SELECT 1')))"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
import IPython
|
|
97
|
+
|
|
98
|
+
IPython.start_ipython(argv=[], user_ns=namespace)
|
|
99
|
+
except ImportError:
|
|
100
|
+
import code
|
|
101
|
+
|
|
102
|
+
code.interact(banner=banner, local=namespace)
|
|
103
|
+
finally:
|
|
104
|
+
asyncio.run(session.close())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# redis
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
@app.command()
|
|
111
|
+
def redis(ctx: typer.Context) -> None:
|
|
112
|
+
"""Start a Python REPL with a Redis async client pre-loaded."""
|
|
113
|
+
actx: AppContext = ctx.obj
|
|
114
|
+
out = actx.output
|
|
115
|
+
|
|
116
|
+
redis_url = actx.redis_url
|
|
117
|
+
if not redis_url:
|
|
118
|
+
out.error("No redis_url configured.")
|
|
119
|
+
raise typer.Exit(1)
|
|
120
|
+
|
|
121
|
+
out.info("Starting Redis shell ...")
|
|
122
|
+
out.info("Available: r (Redis client), asyncio")
|
|
123
|
+
|
|
124
|
+
from kctl_api.core.redis import get_redis
|
|
125
|
+
|
|
126
|
+
r = get_redis(redis_url)
|
|
127
|
+
|
|
128
|
+
import asyncio
|
|
129
|
+
|
|
130
|
+
namespace = {
|
|
131
|
+
"actx": actx,
|
|
132
|
+
"out": out,
|
|
133
|
+
"r": r,
|
|
134
|
+
"redis": r,
|
|
135
|
+
"asyncio": asyncio,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
banner = "kctl-api redis shell\nAvailable: r (Redis client), asyncio\nExample: asyncio.run(r.info())"
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
import IPython
|
|
142
|
+
|
|
143
|
+
IPython.start_ipython(argv=[], user_ns=namespace)
|
|
144
|
+
except ImportError:
|
|
145
|
+
import code
|
|
146
|
+
|
|
147
|
+
code.interact(banner=banner, local=namespace)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# api
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
@app.command()
|
|
154
|
+
def api(ctx: typer.Context) -> None:
|
|
155
|
+
"""Start a Python REPL with an httpx client pre-loaded for API calls."""
|
|
156
|
+
actx: AppContext = ctx.obj
|
|
157
|
+
out = actx.output
|
|
158
|
+
|
|
159
|
+
out.info("Starting API shell ...")
|
|
160
|
+
out.info("Available: client (ApiClient), http (httpx), base_url")
|
|
161
|
+
|
|
162
|
+
import httpx
|
|
163
|
+
|
|
164
|
+
base_url = actx.client.base_url
|
|
165
|
+
api_key = actx.client.api_key
|
|
166
|
+
|
|
167
|
+
http = httpx.Client(
|
|
168
|
+
base_url=base_url,
|
|
169
|
+
headers={"Authorization": f"Bearer {api_key}"} if api_key else {},
|
|
170
|
+
timeout=30,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
namespace = {
|
|
174
|
+
"actx": actx,
|
|
175
|
+
"client": actx.client,
|
|
176
|
+
"http": http,
|
|
177
|
+
"httpx": httpx,
|
|
178
|
+
"base_url": base_url,
|
|
179
|
+
"out": out,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
banner = (
|
|
183
|
+
f"kctl-api api shell — connected to {base_url}\n"
|
|
184
|
+
"Available: client (ApiClient), http (httpx.Client), base_url\n"
|
|
185
|
+
"Example: http.get('/api/v1/health').json()"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
import IPython
|
|
190
|
+
|
|
191
|
+
IPython.start_ipython(argv=[], user_ns=namespace)
|
|
192
|
+
except ImportError:
|
|
193
|
+
import code
|
|
194
|
+
|
|
195
|
+
code.interact(banner=banner, local=namespace)
|
|
196
|
+
finally:
|
|
197
|
+
http.close()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Skill generation for Claude Code integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from kctl_api.core.callbacks import AppContext
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Claude Code skill management.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def generate(
|
|
17
|
+
ctx: typer.Context,
|
|
18
|
+
output: Annotated[str, typer.Option("--output", "-o", help="Output directory")] = "",
|
|
19
|
+
install: Annotated[bool, typer.Option("--install", help="Install to ~/.claude/skills/")] = False,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Auto-generate SKILL.md from CLI command registry."""
|
|
22
|
+
actx: AppContext = ctx.obj
|
|
23
|
+
out = actx.output
|
|
24
|
+
from kctl_lib.skill_generator import generate_skill
|
|
25
|
+
|
|
26
|
+
from kctl_api.cli import app as cli_app
|
|
27
|
+
|
|
28
|
+
skill_name = "api-admin"
|
|
29
|
+
description = "FastAPI platform management via kctl-api CLI"
|
|
30
|
+
|
|
31
|
+
# Canonical in-repo skill dir — source of SKILL.extra.md regardless
|
|
32
|
+
# of where output goes. Without this, --install writes to
|
|
33
|
+
# ~/.claude/skills/ and the handwritten runbook vanishes.
|
|
34
|
+
cli_root = Path(__file__).resolve().parents[3]
|
|
35
|
+
source_dir = cli_root / "skills" / skill_name
|
|
36
|
+
|
|
37
|
+
# Determine output directory
|
|
38
|
+
if output:
|
|
39
|
+
output_dir = Path(output)
|
|
40
|
+
elif install:
|
|
41
|
+
output_dir = Path.home() / ".claude" / "skills" / skill_name
|
|
42
|
+
else:
|
|
43
|
+
output_dir = source_dir
|
|
44
|
+
|
|
45
|
+
# Extra content always lives at the in-repo location.
|
|
46
|
+
extra = source_dir / "SKILL.extra.md"
|
|
47
|
+
|
|
48
|
+
generate_skill(
|
|
49
|
+
cli_app,
|
|
50
|
+
"kctl-api",
|
|
51
|
+
skill_name,
|
|
52
|
+
description,
|
|
53
|
+
output_dir=output_dir,
|
|
54
|
+
extra_file=extra if extra.exists() else None,
|
|
55
|
+
)
|
|
56
|
+
out.success(f"Generated {output_dir / 'SKILL.md'}")
|
|
57
|
+
if install:
|
|
58
|
+
out.success(f"Installed to ~/.claude/skills/{skill_name}/")
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Redis Streams management commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
List, read, trim, and manage dead-letter queues.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from kctl_api.core.callbacks import AppContext
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(name="streams", help="Redis Streams — list, read, trim, dead-letter.", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# list
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
@app.command(name="list")
|
|
22
|
+
def list_streams(ctx: typer.Context) -> None:
|
|
23
|
+
"""List all Redis Streams via SCAN for stream-type keys."""
|
|
24
|
+
actx: AppContext = ctx.obj
|
|
25
|
+
out = actx.output
|
|
26
|
+
|
|
27
|
+
redis_url = actx.redis_url
|
|
28
|
+
if not redis_url:
|
|
29
|
+
out.error("No redis_url configured.")
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
|
|
32
|
+
async def _run() -> list[dict]:
|
|
33
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
client = get_redis(redis_url)
|
|
37
|
+
streams: list[dict] = []
|
|
38
|
+
async for key in client.scan_iter(match="*", count=200):
|
|
39
|
+
key_type = await client.type(key)
|
|
40
|
+
if key_type == "stream":
|
|
41
|
+
length = await client.xlen(key)
|
|
42
|
+
streams.append({"name": key, "length": length})
|
|
43
|
+
return streams
|
|
44
|
+
finally:
|
|
45
|
+
await close_redis()
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
streams = asyncio.run(_run())
|
|
49
|
+
except ImportError as e:
|
|
50
|
+
out.error(str(e))
|
|
51
|
+
raise typer.Exit(1) from None
|
|
52
|
+
except Exception as e:
|
|
53
|
+
out.error(f"Redis error: {e}")
|
|
54
|
+
raise typer.Exit(1) from None
|
|
55
|
+
|
|
56
|
+
rows = [[s["name"], str(s["length"])] for s in streams]
|
|
57
|
+
|
|
58
|
+
out.table(
|
|
59
|
+
title=f"Redis Streams ({len(streams)})",
|
|
60
|
+
columns=[("Stream", "bold"), ("Length", "")],
|
|
61
|
+
rows=rows,
|
|
62
|
+
data_for_json=streams,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# read
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
@app.command()
|
|
70
|
+
def read(
|
|
71
|
+
ctx: typer.Context,
|
|
72
|
+
stream_name: Annotated[str, typer.Argument(help="Stream name to read from.")],
|
|
73
|
+
count: Annotated[int, typer.Option("--count", "-n", help="Number of messages to read.")] = 10,
|
|
74
|
+
start: Annotated[str, typer.Option("--start", help="Start ID (default: oldest).")] = "0-0",
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Read messages from a Redis Stream via XRANGE."""
|
|
77
|
+
actx: AppContext = ctx.obj
|
|
78
|
+
out = actx.output
|
|
79
|
+
|
|
80
|
+
redis_url = actx.redis_url
|
|
81
|
+
if not redis_url:
|
|
82
|
+
out.error("No redis_url configured.")
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
|
|
85
|
+
async def _run() -> list[dict]:
|
|
86
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
client = get_redis(redis_url)
|
|
90
|
+
messages = await client.xrange(stream_name, min=start, count=count)
|
|
91
|
+
return [{"id": msg_id, "data": data} for msg_id, data in messages]
|
|
92
|
+
finally:
|
|
93
|
+
await close_redis()
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
messages = asyncio.run(_run())
|
|
97
|
+
except Exception as e:
|
|
98
|
+
out.error(f"Redis error: {e}")
|
|
99
|
+
raise typer.Exit(1) from None
|
|
100
|
+
|
|
101
|
+
if actx.json_mode:
|
|
102
|
+
out.raw_json(messages)
|
|
103
|
+
else:
|
|
104
|
+
for msg in messages:
|
|
105
|
+
out.text(f"[bold]{msg['id']}[/bold]")
|
|
106
|
+
for k, v in msg["data"].items():
|
|
107
|
+
out.kv(f" {k}", str(v))
|
|
108
|
+
out.info(f"{len(messages)} message(s) read from '{stream_name}'.")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# trim
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
@app.command()
|
|
115
|
+
def trim(
|
|
116
|
+
ctx: typer.Context,
|
|
117
|
+
stream_name: Annotated[str, typer.Argument(help="Stream name to trim.")],
|
|
118
|
+
maxlen: Annotated[int, typer.Option("--maxlen", "-n", help="Maximum stream length after trim.")] = 1000,
|
|
119
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Trim a Redis Stream to a maximum length via XTRIM."""
|
|
122
|
+
actx: AppContext = ctx.obj
|
|
123
|
+
out = actx.output
|
|
124
|
+
|
|
125
|
+
redis_url = actx.redis_url
|
|
126
|
+
if not redis_url:
|
|
127
|
+
out.error("No redis_url configured.")
|
|
128
|
+
raise typer.Exit(1)
|
|
129
|
+
|
|
130
|
+
if not force:
|
|
131
|
+
confirm = typer.confirm(f"Trim stream '{stream_name}' to maxlen={maxlen}?", default=False)
|
|
132
|
+
if not confirm:
|
|
133
|
+
out.info("Cancelled.")
|
|
134
|
+
raise typer.Exit(0)
|
|
135
|
+
|
|
136
|
+
async def _run() -> int:
|
|
137
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
client = get_redis(redis_url)
|
|
141
|
+
return await client.xtrim(stream_name, maxlen=maxlen)
|
|
142
|
+
finally:
|
|
143
|
+
await close_redis()
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
trimmed = asyncio.run(_run())
|
|
147
|
+
except Exception as e:
|
|
148
|
+
out.error(f"Redis error: {e}")
|
|
149
|
+
raise typer.Exit(1) from None
|
|
150
|
+
|
|
151
|
+
out.success(f"Trimmed {trimmed} message(s) from '{stream_name}'.")
|
|
152
|
+
if actx.json_mode:
|
|
153
|
+
out.raw_json({"stream": stream_name, "trimmed": trimmed, "maxlen": maxlen})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# dead-letter
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
@app.command(name="dead-letter")
|
|
160
|
+
def dead_letter(
|
|
161
|
+
ctx: typer.Context,
|
|
162
|
+
stream_name: Annotated[str, typer.Argument(help="Dead-letter stream name.")] = "dead-letter",
|
|
163
|
+
count: Annotated[int, typer.Option("--count", "-n", help="Number of messages to read.")] = 20,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Read messages from the dead-letter stream."""
|
|
166
|
+
actx: AppContext = ctx.obj
|
|
167
|
+
out = actx.output
|
|
168
|
+
|
|
169
|
+
redis_url = actx.redis_url
|
|
170
|
+
if not redis_url:
|
|
171
|
+
out.error("No redis_url configured.")
|
|
172
|
+
raise typer.Exit(1)
|
|
173
|
+
|
|
174
|
+
async def _run() -> list[dict]:
|
|
175
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
client = get_redis(redis_url)
|
|
179
|
+
messages = await client.xrange(stream_name, count=count)
|
|
180
|
+
return [{"id": msg_id, "data": data} for msg_id, data in messages]
|
|
181
|
+
finally:
|
|
182
|
+
await close_redis()
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
messages = asyncio.run(_run())
|
|
186
|
+
except Exception as e:
|
|
187
|
+
out.error(f"Redis error: {e}")
|
|
188
|
+
raise typer.Exit(1) from None
|
|
189
|
+
|
|
190
|
+
if not messages:
|
|
191
|
+
out.info("Dead-letter stream is empty.")
|
|
192
|
+
if actx.json_mode:
|
|
193
|
+
out.raw_json([])
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
if actx.json_mode:
|
|
197
|
+
out.raw_json(messages)
|
|
198
|
+
else:
|
|
199
|
+
for msg in messages:
|
|
200
|
+
out.text(f"[bold red]{msg['id']}[/bold red]")
|
|
201
|
+
for k, v in msg["data"].items():
|
|
202
|
+
out.kv(f" {k}", str(v))
|
|
203
|
+
out.warn(f"{len(messages)} dead-letter message(s).")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# replay
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
@app.command()
|
|
210
|
+
def replay(
|
|
211
|
+
ctx: typer.Context,
|
|
212
|
+
source_stream: Annotated[str, typer.Argument(help="Source stream (dead-letter).")],
|
|
213
|
+
target_stream: Annotated[str, typer.Argument(help="Target stream to replay to.")],
|
|
214
|
+
count: Annotated[int, typer.Option("--count", "-n", help="Number of messages to replay.")] = 10,
|
|
215
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Replay messages from one stream to another via XRANGE + XADD."""
|
|
218
|
+
actx: AppContext = ctx.obj
|
|
219
|
+
out = actx.output
|
|
220
|
+
|
|
221
|
+
redis_url = actx.redis_url
|
|
222
|
+
if not redis_url:
|
|
223
|
+
out.error("No redis_url configured.")
|
|
224
|
+
raise typer.Exit(1)
|
|
225
|
+
|
|
226
|
+
if not force:
|
|
227
|
+
confirm = typer.confirm(f"Replay {count} messages from '{source_stream}' to '{target_stream}'?", default=False)
|
|
228
|
+
if not confirm:
|
|
229
|
+
out.info("Cancelled.")
|
|
230
|
+
raise typer.Exit(0)
|
|
231
|
+
|
|
232
|
+
async def _run() -> int:
|
|
233
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
client = get_redis(redis_url)
|
|
237
|
+
messages = await client.xrange(source_stream, count=count)
|
|
238
|
+
replayed = 0
|
|
239
|
+
for _msg_id, data in messages:
|
|
240
|
+
await client.xadd(target_stream, data)
|
|
241
|
+
replayed += 1
|
|
242
|
+
return replayed
|
|
243
|
+
finally:
|
|
244
|
+
await close_redis()
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
replayed = asyncio.run(_run())
|
|
248
|
+
except Exception as e:
|
|
249
|
+
out.error(f"Redis error: {e}")
|
|
250
|
+
raise typer.Exit(1) from None
|
|
251
|
+
|
|
252
|
+
out.success(f"Replayed {replayed} message(s) from '{source_stream}' to '{target_stream}'.")
|
|
253
|
+
if actx.json_mode:
|
|
254
|
+
out.raw_json({"source": source_stream, "target": target_stream, "replayed": replayed})
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# consumers
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
@app.command()
|
|
261
|
+
def consumers(
|
|
262
|
+
ctx: typer.Context,
|
|
263
|
+
stream_name: Annotated[str, typer.Argument(help="Stream name.")],
|
|
264
|
+
group: Annotated[str, typer.Argument(help="Consumer group name.")],
|
|
265
|
+
) -> None:
|
|
266
|
+
"""List consumers in a consumer group via XINFO CONSUMERS."""
|
|
267
|
+
actx: AppContext = ctx.obj
|
|
268
|
+
out = actx.output
|
|
269
|
+
|
|
270
|
+
redis_url = actx.redis_url
|
|
271
|
+
if not redis_url:
|
|
272
|
+
out.error("No redis_url configured.")
|
|
273
|
+
raise typer.Exit(1)
|
|
274
|
+
|
|
275
|
+
async def _run() -> list[dict]:
|
|
276
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
client = get_redis(redis_url)
|
|
280
|
+
info = await client.xinfo_consumers(stream_name, group)
|
|
281
|
+
return [dict(c) for c in info] if info else []
|
|
282
|
+
finally:
|
|
283
|
+
await close_redis()
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
consumer_list = asyncio.run(_run())
|
|
287
|
+
except Exception as e:
|
|
288
|
+
out.error(f"Redis error: {e}")
|
|
289
|
+
raise typer.Exit(1) from None
|
|
290
|
+
|
|
291
|
+
rows = [
|
|
292
|
+
[
|
|
293
|
+
str(c.get("name", "")),
|
|
294
|
+
str(c.get("pending", 0)),
|
|
295
|
+
str(c.get("idle", 0)),
|
|
296
|
+
]
|
|
297
|
+
for c in consumer_list
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
out.table(
|
|
301
|
+
title=f"Consumers: {stream_name} / {group}",
|
|
302
|
+
columns=[
|
|
303
|
+
("Consumer", "bold"),
|
|
304
|
+
("Pending", ""),
|
|
305
|
+
("Idle (ms)", ""),
|
|
306
|
+
],
|
|
307
|
+
rows=rows,
|
|
308
|
+
data_for_json=consumer_list,
|
|
309
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Stripe integration commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Checkout sessions, customer portal, and webhook status.
|
|
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="stripe", help="Stripe integration — checkout, portal, webhooks.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
_BASE = "/api/v1/stripe"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# checkout
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
@app.command()
|
|
25
|
+
def checkout(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
price_id: Annotated[str, typer.Argument(help="Stripe Price ID.")],
|
|
28
|
+
success_url: Annotated[str, typer.Option("--success-url", help="Redirect URL on success.")] = "",
|
|
29
|
+
cancel_url: Annotated[str, typer.Option("--cancel-url", help="Redirect URL on cancel.")] = "",
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Create a Stripe checkout session via POST /api/v1/stripe/checkout."""
|
|
32
|
+
actx: AppContext = ctx.obj
|
|
33
|
+
out = actx.output
|
|
34
|
+
|
|
35
|
+
payload: dict = {"price_id": price_id}
|
|
36
|
+
if success_url:
|
|
37
|
+
payload["success_url"] = success_url
|
|
38
|
+
if cancel_url:
|
|
39
|
+
payload["cancel_url"] = cancel_url
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
result = actx.client.post(f"{_BASE}/checkout", json=payload)
|
|
43
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
44
|
+
out.error(str(e))
|
|
45
|
+
raise typer.Exit(1) from None
|
|
46
|
+
|
|
47
|
+
checkout_url = result.get("url", "") if isinstance(result, dict) else ""
|
|
48
|
+
if checkout_url:
|
|
49
|
+
out.success(f"Checkout session created: {checkout_url}")
|
|
50
|
+
else:
|
|
51
|
+
out.success("Checkout session created.")
|
|
52
|
+
if actx.json_mode:
|
|
53
|
+
out.raw_json(result)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# portal
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
@app.command()
|
|
60
|
+
def portal(ctx: typer.Context) -> None:
|
|
61
|
+
"""Create a Stripe customer portal session via POST /api/v1/stripe/portal."""
|
|
62
|
+
actx: AppContext = ctx.obj
|
|
63
|
+
out = actx.output
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
result = actx.client.post(f"{_BASE}/portal")
|
|
67
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
68
|
+
out.error(str(e))
|
|
69
|
+
raise typer.Exit(1) from None
|
|
70
|
+
|
|
71
|
+
portal_url = result.get("url", "") if isinstance(result, dict) else ""
|
|
72
|
+
if portal_url:
|
|
73
|
+
out.success(f"Portal session created: {portal_url}")
|
|
74
|
+
else:
|
|
75
|
+
out.success("Portal session created.")
|
|
76
|
+
if actx.json_mode:
|
|
77
|
+
out.raw_json(result)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# webhook-status
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
@app.command(name="webhook-status")
|
|
84
|
+
def webhook_status(ctx: typer.Context) -> None:
|
|
85
|
+
"""Check Stripe webhook endpoint status via GET /api/v1/stripe/webhook-status."""
|
|
86
|
+
actx: AppContext = ctx.obj
|
|
87
|
+
out = actx.output
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
data = actx.client.get(f"{_BASE}/webhook-status")
|
|
91
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
92
|
+
out.error(str(e))
|
|
93
|
+
raise typer.Exit(1) from None
|
|
94
|
+
|
|
95
|
+
if not data:
|
|
96
|
+
out.info("No webhook status available.")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
out.detail(
|
|
100
|
+
title="Stripe Webhook Status",
|
|
101
|
+
sections=[
|
|
102
|
+
("Status", [(k, str(v)) for k, v in data.items()]),
|
|
103
|
+
],
|
|
104
|
+
data_for_json=data,
|
|
105
|
+
)
|