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