kstlib 0.0.1a0__py3-none-any.whl → 1.0.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 (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,252 @@
1
+ """List all sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as json_lib
6
+ import logging
7
+ from typing import Any
8
+
9
+ from rich.table import Table
10
+
11
+ from kstlib.cli.common import console
12
+ from kstlib.config import get_config
13
+ from kstlib.ops import (
14
+ ContainerRunner,
15
+ SessionStatus,
16
+ TmuxRunner,
17
+ )
18
+ from kstlib.ops.exceptions import BackendNotFoundError
19
+ from kstlib.ops.models import BackendType, SessionState
20
+ from kstlib.ops.validators import (
21
+ validate_command,
22
+ validate_env,
23
+ validate_image_name,
24
+ validate_ports,
25
+ validate_session_name,
26
+ validate_volumes,
27
+ )
28
+
29
+ from .common import (
30
+ BACKEND_OPTION,
31
+ JSON_OPTION,
32
+ )
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+ # Maximum number of config sessions to prevent DoS via large config
37
+ _MAX_CONFIG_SESSIONS = 50
38
+
39
+ _VALID_BACKENDS = {"tmux", "container"}
40
+
41
+ _STATE_STYLES: dict[str, str] = {
42
+ "running": "green",
43
+ "defined": "dim",
44
+ "exited": "yellow",
45
+ "stopped": "yellow",
46
+ "unknown": "red",
47
+ }
48
+
49
+
50
+ def _load_config_sessions() -> dict[str, dict[str, Any]]:
51
+ """Load session definitions from kstlib configuration.
52
+
53
+ Reads ``ops.sessions`` from the config file and validates
54
+ each entry with deep defense checks.
55
+
56
+ Returns:
57
+ Validated session configs keyed by session name.
58
+ """
59
+ config = get_config()
60
+ ops_config: dict[str, Any] = config.get("ops", {}) # type: ignore[no-untyped-call]
61
+ raw_sessions: Any = ops_config.get("sessions", {})
62
+
63
+ # Deep defense: sessions must be a dict
64
+ if not isinstance(raw_sessions, dict):
65
+ log.warning("ops.sessions is not a dict, ignoring config sessions")
66
+ return {}
67
+
68
+ # Deep defense: limit number of sessions
69
+ if len(raw_sessions) > _MAX_CONFIG_SESSIONS:
70
+ log.warning(
71
+ "ops.sessions has %d entries (max %d), truncating",
72
+ len(raw_sessions),
73
+ _MAX_CONFIG_SESSIONS,
74
+ )
75
+ # Take only first N entries
76
+ raw_sessions = dict(list(raw_sessions.items())[:_MAX_CONFIG_SESSIONS])
77
+
78
+ validated: dict[str, dict[str, Any]] = {}
79
+ for name, data in raw_sessions.items():
80
+ if not isinstance(data, dict):
81
+ log.warning("Session '%s' config is not a dict, skipping", name)
82
+ continue
83
+
84
+ try:
85
+ _validate_config_session(name, data)
86
+ except ValueError as exc:
87
+ log.warning("Invalid config session '%s': %s", name, exc)
88
+ continue
89
+
90
+ validated[name] = data
91
+
92
+ return validated
93
+
94
+
95
+ def _validate_config_session(name: str, data: dict[str, Any]) -> None:
96
+ """Validate a single config session entry.
97
+
98
+ Args:
99
+ name: Session name (YAML key).
100
+ data: Session configuration dict.
101
+
102
+ Raises:
103
+ ValueError: If any field is invalid.
104
+ """
105
+ validate_session_name(name)
106
+
107
+ backend = data.get("backend", "tmux")
108
+ if backend not in _VALID_BACKENDS:
109
+ raise ValueError(f"Invalid backend '{backend}'")
110
+
111
+ image = data.get("image")
112
+ if image is not None:
113
+ validate_image_name(image)
114
+
115
+ command = data.get("command")
116
+ if command is not None:
117
+ validate_command(command)
118
+
119
+ env = data.get("env")
120
+ if env is not None:
121
+ if not isinstance(env, dict):
122
+ raise ValueError("env must be a dict")
123
+ validate_env(env)
124
+
125
+ volumes = data.get("volumes")
126
+ if volumes is not None:
127
+ if not isinstance(volumes, list):
128
+ raise ValueError("volumes must be a list")
129
+ validate_volumes(volumes)
130
+
131
+ ports = data.get("ports")
132
+ if ports is not None:
133
+ if not isinstance(ports, list):
134
+ raise ValueError("ports must be a list")
135
+ validate_ports(ports)
136
+
137
+
138
+ def _collect_sessions(backend: str | None) -> list[SessionStatus]:
139
+ """Collect sessions from runtime backends and config definitions.
140
+
141
+ Merges runtime sessions with config-defined sessions. Config sessions
142
+ that are not currently running appear with state DEFINED.
143
+
144
+ Args:
145
+ backend: Backend to query, or None for all.
146
+
147
+ Returns:
148
+ List of SessionStatus from queried backends plus config-defined sessions.
149
+ """
150
+ sessions: list[SessionStatus] = []
151
+
152
+ # Collect from tmux
153
+ if backend is None or backend == "tmux":
154
+ try:
155
+ tmux = TmuxRunner()
156
+ sessions.extend(tmux.list_sessions())
157
+ except BackendNotFoundError:
158
+ pass
159
+
160
+ # Collect from container
161
+ if backend is None or backend == "container":
162
+ try:
163
+ container = ContainerRunner()
164
+ sessions.extend(container.list_sessions())
165
+ except BackendNotFoundError:
166
+ pass
167
+
168
+ # Merge config-defined sessions
169
+ runtime_names: set[str] = {s.name for s in sessions}
170
+ config_sessions = _load_config_sessions()
171
+
172
+ for name, data in config_sessions.items():
173
+ if name in runtime_names:
174
+ continue
175
+
176
+ session_backend = data.get("backend", "tmux")
177
+
178
+ # Apply backend filter to config sessions too
179
+ if backend is not None and session_backend != backend:
180
+ continue
181
+
182
+ backend_type = BackendType(session_backend)
183
+ sessions.append(
184
+ SessionStatus(
185
+ name=name,
186
+ state=SessionState.DEFINED,
187
+ backend=backend_type,
188
+ image=data.get("image"),
189
+ ),
190
+ )
191
+
192
+ return sessions
193
+
194
+
195
+ def list_sessions(
196
+ backend: str | None = BACKEND_OPTION,
197
+ json: bool = JSON_OPTION,
198
+ ) -> None:
199
+ """List all sessions.
200
+
201
+ Shows all tmux sessions and containers managed by the ops module,
202
+ plus config-defined sessions that have not been started yet.
203
+ Can be filtered by backend type.
204
+
205
+ Examples:
206
+ kstlib ops list
207
+ kstlib ops list --backend tmux
208
+ kstlib ops list --backend container --json
209
+ """
210
+ sessions = _collect_sessions(backend)
211
+
212
+ if json:
213
+ # JSON output
214
+ data = [
215
+ {
216
+ "name": s.name,
217
+ "state": s.state.value,
218
+ "backend": s.backend.value,
219
+ "pid": s.pid,
220
+ "image": s.image,
221
+ }
222
+ for s in sessions
223
+ ]
224
+ console.print(json_lib.dumps(data, indent=2))
225
+ return
226
+
227
+ if not sessions:
228
+ console.print("[dim]No sessions found.[/]")
229
+ return
230
+
231
+ # Rich table output
232
+ table = Table(title="Sessions")
233
+ table.add_column("Name", style="cyan")
234
+ table.add_column("State", style="white")
235
+ table.add_column("Backend", style="blue")
236
+ table.add_column("PID", style="dim")
237
+ table.add_column("Image", style="dim")
238
+
239
+ for session in sessions:
240
+ state_style = _STATE_STYLES.get(session.state.value, "yellow")
241
+ table.add_row(
242
+ session.name,
243
+ f"[{state_style}]{session.state.value}[/]",
244
+ session.backend.value,
245
+ str(session.pid) if session.pid else "-",
246
+ session.image or "-",
247
+ )
248
+
249
+ console.print(table)
250
+
251
+
252
+ __all__ = ["list_sessions"]
@@ -0,0 +1,49 @@
1
+ """Show session logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from kstlib.cli.common import console, exit_error
6
+ from kstlib.ops.exceptions import OpsError, SessionNotFoundError
7
+
8
+ from .common import (
9
+ BACKEND_OPTION,
10
+ LINES_OPTION,
11
+ SESSION_ARGUMENT,
12
+ get_session_manager,
13
+ )
14
+
15
+
16
+ def logs(
17
+ name: str = SESSION_ARGUMENT,
18
+ backend: str | None = BACKEND_OPTION,
19
+ lines: int = LINES_OPTION,
20
+ ) -> None:
21
+ """Show logs from a session.
22
+
23
+ Retrieves and displays recent output from a tmux session or container.
24
+ ANSI color codes are preserved in the output.
25
+
26
+ Examples:
27
+ kstlib ops logs dev
28
+ kstlib ops logs prod --lines 50
29
+ """
30
+ try:
31
+ manager = get_session_manager(name, backend=backend)
32
+
33
+ if not manager.exists():
34
+ exit_error(f"Session '{name}' not found.")
35
+
36
+ log_content = manager.logs(lines=lines)
37
+
38
+ if log_content.strip():
39
+ console.print(log_content, markup=False)
40
+ else:
41
+ console.print(f"[dim]No logs available for session '{name}'.[/]")
42
+
43
+ except SessionNotFoundError:
44
+ exit_error(f"Session '{name}' not found.")
45
+ except OpsError as e:
46
+ exit_error(str(e))
47
+
48
+
49
+ __all__ = ["logs"]
@@ -0,0 +1,98 @@
1
+ """Start a session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from kstlib.cli.common import (
6
+ CommandResult,
7
+ CommandStatus,
8
+ emit_result,
9
+ exit_error,
10
+ )
11
+ from kstlib.ops.exceptions import OpsError, SessionExistsError
12
+
13
+ from .common import (
14
+ BACKEND_OPTION,
15
+ COMMAND_OPTION,
16
+ ENV_OPTION,
17
+ IMAGE_OPTION,
18
+ PORT_OPTION,
19
+ QUIET_OPTION,
20
+ SESSION_ARGUMENT,
21
+ VOLUME_OPTION,
22
+ WORKDIR_OPTION,
23
+ get_session_manager,
24
+ )
25
+
26
+
27
+ def start( # noqa: PLR0913
28
+ name: str = SESSION_ARGUMENT,
29
+ backend: str | None = BACKEND_OPTION,
30
+ command: str | None = COMMAND_OPTION,
31
+ image: str | None = IMAGE_OPTION,
32
+ quiet: bool = QUIET_OPTION,
33
+ working_dir: str | None = WORKDIR_OPTION,
34
+ env: list[str] | None = ENV_OPTION,
35
+ volume: list[str] | None = VOLUME_OPTION,
36
+ port: list[str] | None = PORT_OPTION,
37
+ ) -> None:
38
+ """Start a new session.
39
+
40
+ Creates and starts a new tmux session or container with the specified
41
+ configuration. If the session is defined in kstlib.conf.yml, those
42
+ settings will be used as defaults.
43
+
44
+ Examples:
45
+ kstlib ops start dev --backend tmux --command "python app.py"
46
+ kstlib ops start prod --backend container --image app:latest
47
+ kstlib ops start astro # Uses config from kstlib.conf.yml
48
+ """
49
+ # Parse environment variables
50
+ env_dict: dict[str, str] = {}
51
+ if env:
52
+ for item in env:
53
+ if "=" in item:
54
+ key, value = item.split("=", 1)
55
+ env_dict[key] = value
56
+ else:
57
+ exit_error(f"Invalid environment variable format: {item}")
58
+
59
+ try:
60
+ manager = get_session_manager(
61
+ name,
62
+ backend=backend,
63
+ image=image,
64
+ command=command,
65
+ )
66
+
67
+ # Build kwargs for start
68
+ kwargs: dict[str, str | list[str] | dict[str, str] | None] = {}
69
+ if working_dir:
70
+ kwargs["working_dir"] = working_dir
71
+ if env_dict:
72
+ kwargs["env"] = env_dict
73
+ if volume:
74
+ kwargs["volumes"] = list(volume)
75
+ if port:
76
+ kwargs["ports"] = list(port)
77
+
78
+ status = manager.start(command, **kwargs)
79
+
80
+ result = CommandResult(
81
+ status=CommandStatus.OK,
82
+ message=f"Session '{name}' started ({status.backend.value} backend).",
83
+ payload={
84
+ "name": status.name,
85
+ "state": status.state.value,
86
+ "backend": status.backend.value,
87
+ "pid": status.pid,
88
+ },
89
+ )
90
+ emit_result(result, quiet)
91
+
92
+ except SessionExistsError:
93
+ exit_error(f"Session '{name}' already exists. Use 'kstlib ops stop {name}' first.")
94
+ except OpsError as e:
95
+ exit_error(str(e))
96
+
97
+
98
+ __all__ = ["start"]
@@ -0,0 +1,138 @@
1
+ """Show session status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as json_lib
6
+
7
+ from rich.table import Table
8
+
9
+ from kstlib.cli.common import console, exit_error
10
+ from kstlib.ops import SessionManager
11
+ from kstlib.ops.exceptions import OpsError, SessionNotFoundError
12
+ from kstlib.ops.models import SessionState, SessionStatus
13
+ from kstlib.utils.formatting import format_timestamp
14
+
15
+ from .common import (
16
+ BACKEND_OPTION,
17
+ JSON_OPTION,
18
+ QUIET_OPTION,
19
+ SESSION_ARGUMENT,
20
+ get_session_manager,
21
+ )
22
+
23
+
24
+ def _config_fallback_status(name: str) -> SessionStatus | None:
25
+ """Try to build a DEFINED status from config for a non-running session.
26
+
27
+ Args:
28
+ name: Session name to look up in config.
29
+
30
+ Returns:
31
+ SessionStatus with DEFINED state, or None if not in config.
32
+ """
33
+ try:
34
+ manager = SessionManager.from_config(name)
35
+ except OpsError:
36
+ return None
37
+
38
+ return SessionStatus(
39
+ name=name,
40
+ state=SessionState.DEFINED,
41
+ backend=manager.config.backend,
42
+ image=manager.config.image,
43
+ )
44
+
45
+
46
+ def _format_created_at(created_at: str | None) -> str | None:
47
+ """Format created_at value for display.
48
+
49
+ Args:
50
+ created_at: Raw created_at value (epoch string or ISO format).
51
+
52
+ Returns:
53
+ Formatted datetime string, or None if no value.
54
+ """
55
+ if not created_at:
56
+ return None
57
+ try:
58
+ float(created_at)
59
+ return format_timestamp(created_at)
60
+ except ValueError:
61
+ # Already formatted (ISO from Docker), keep as-is
62
+ return created_at
63
+
64
+
65
+ def status(
66
+ name: str = SESSION_ARGUMENT,
67
+ backend: str | None = BACKEND_OPTION,
68
+ quiet: bool = QUIET_OPTION,
69
+ json: bool = JSON_OPTION,
70
+ ) -> None:
71
+ """Show status of a session.
72
+
73
+ Displays detailed information about a session including its state,
74
+ PID, backend type, and other relevant details.
75
+
76
+ Examples:
77
+ kstlib ops status dev
78
+ kstlib ops status prod --json
79
+ """
80
+ try:
81
+ manager = get_session_manager(name, backend=backend)
82
+
83
+ if not manager.exists():
84
+ # Try config fallback for defined-but-not-started sessions
85
+ session_status = _config_fallback_status(name)
86
+ if session_status is None:
87
+ exit_error(f"Session '{name}' not found.")
88
+ else:
89
+ session_status = manager.status()
90
+
91
+ if json:
92
+ # JSON output
93
+ data = {
94
+ "name": session_status.name,
95
+ "state": session_status.state.value,
96
+ "backend": session_status.backend.value,
97
+ "pid": session_status.pid,
98
+ "created_at": session_status.created_at,
99
+ "window_count": session_status.window_count,
100
+ "image": session_status.image,
101
+ "exit_code": session_status.exit_code,
102
+ }
103
+ console.print(json_lib.dumps(data, indent=2))
104
+ return
105
+
106
+ if quiet:
107
+ console.print(f"{name}: {session_status.state.value}")
108
+ return
109
+
110
+ # Rich table output
111
+ table = Table(title=f"Session: {name}")
112
+ table.add_column("Property", style="cyan")
113
+ table.add_column("Value", style="white")
114
+
115
+ table.add_row("State", session_status.state.value)
116
+ table.add_row("Backend", session_status.backend.value)
117
+
118
+ # Add optional fields as rows (reduces branching complexity)
119
+ optional_rows = [
120
+ ("PID", str(session_status.pid) if session_status.pid else None),
121
+ ("Created", _format_created_at(session_status.created_at)),
122
+ ("Windows", str(session_status.window_count) if session_status.window_count > 0 else None),
123
+ ("Image", session_status.image),
124
+ ("Exit Code", str(session_status.exit_code) if session_status.exit_code is not None else None),
125
+ ]
126
+ for label, value in optional_rows:
127
+ if value:
128
+ table.add_row(label, value)
129
+
130
+ console.print(table)
131
+
132
+ except SessionNotFoundError:
133
+ exit_error(f"Session '{name}' not found.")
134
+ except OpsError as e:
135
+ exit_error(str(e))
136
+
137
+
138
+ __all__ = ["status"]
@@ -0,0 +1,60 @@
1
+ """Stop a session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from kstlib.cli.common import (
6
+ CommandResult,
7
+ CommandStatus,
8
+ emit_result,
9
+ exit_error,
10
+ )
11
+ from kstlib.ops.exceptions import OpsError, SessionNotFoundError
12
+
13
+ from .common import (
14
+ BACKEND_OPTION,
15
+ FORCE_OPTION,
16
+ QUIET_OPTION,
17
+ SESSION_ARGUMENT,
18
+ TIMEOUT_OPTION,
19
+ get_session_manager,
20
+ )
21
+
22
+
23
+ def stop(
24
+ name: str = SESSION_ARGUMENT,
25
+ backend: str | None = BACKEND_OPTION,
26
+ quiet: bool = QUIET_OPTION,
27
+ force: bool = FORCE_OPTION,
28
+ timeout: int = TIMEOUT_OPTION,
29
+ ) -> None:
30
+ """Stop a running session.
31
+
32
+ Stops a tmux session or container. By default, attempts a graceful
33
+ shutdown first, then forces termination if the timeout is exceeded.
34
+
35
+ Examples:
36
+ kstlib ops stop dev
37
+ kstlib ops stop prod --force
38
+ kstlib ops stop bot --timeout 30
39
+ """
40
+ try:
41
+ manager = get_session_manager(name, backend=backend)
42
+
43
+ if not manager.exists():
44
+ exit_error(f"Session '{name}' not found.")
45
+
46
+ manager.stop(graceful=not force, timeout=timeout)
47
+
48
+ result = CommandResult(
49
+ status=CommandStatus.OK,
50
+ message=f"Session '{name}' stopped.",
51
+ )
52
+ emit_result(result, quiet)
53
+
54
+ except SessionNotFoundError:
55
+ exit_error(f"Session '{name}' not found.")
56
+ except OpsError as e:
57
+ exit_error(str(e))
58
+
59
+
60
+ __all__ = ["stop"]
@@ -0,0 +1,60 @@
1
+ """CLI commands for REST API client (rapi)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ import typer
7
+ from typer.core import TyperGroup
8
+
9
+ from .call import call
10
+ from .list import list_endpoints
11
+ from .show import show_endpoint
12
+
13
+ # Known subcommands that should not be treated as endpoints
14
+ _SUBCOMMANDS = {"list", "call", "show", "--help", "-h", "help"}
15
+
16
+
17
+ class RapiGroup(TyperGroup):
18
+ """Custom Typer Group that treats unknown commands as endpoint calls."""
19
+
20
+ def resolve_command(
21
+ self,
22
+ ctx: click.Context,
23
+ args: list[str],
24
+ ) -> tuple[str | None, click.Command | None, list[str]]:
25
+ """Override command resolution to treat unknown commands as endpoints."""
26
+ # Try normal resolution first
27
+ try:
28
+ return super().resolve_command(ctx, args)
29
+ except click.UsageError:
30
+ # If command not found and looks like an endpoint, redirect to call
31
+ if args and args[0] not in _SUBCOMMANDS and "." in args[0]:
32
+ # Treat as implicit call: prepend "call" to args
33
+ return super().resolve_command(ctx, ["call", *args])
34
+ raise
35
+
36
+
37
+ rapi_app = typer.Typer(
38
+ help="Config-driven REST API client.",
39
+ cls=RapiGroup,
40
+ )
41
+
42
+ # Register explicit commands
43
+ rapi_app.command(name="list")(list_endpoints)
44
+ rapi_app.command(name="show")(show_endpoint)
45
+ # Keep "call" for explicit usage (shown in help)
46
+ rapi_app.command(name="call", hidden=False)(call)
47
+
48
+
49
+ def register_cli(app: typer.Typer) -> None:
50
+ """Register the rapi sub-commands on the root Typer app."""
51
+ app.add_typer(rapi_app, name="rapi")
52
+
53
+
54
+ __all__ = [
55
+ "call",
56
+ "list_endpoints",
57
+ "rapi_app",
58
+ "register_cli",
59
+ "show_endpoint",
60
+ ]