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
kstlib/ops/models.py ADDED
@@ -0,0 +1,176 @@
1
+ """Data models for the kstlib.ops module.
2
+
3
+ This module defines the core data structures used by the ops module:
4
+
5
+ - BackendType: Enum for backend selection (tmux, container)
6
+ - SessionState: Enum for session state (running, stopped, exited, unknown)
7
+ - SessionConfig: Configuration for creating a session
8
+ - SessionStatus: Current status of a session
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+
16
+ from kstlib.ops.validators import (
17
+ validate_command,
18
+ validate_env,
19
+ validate_image_name,
20
+ validate_ports,
21
+ validate_session_name,
22
+ validate_volumes,
23
+ )
24
+
25
+
26
+ class BackendType(str, Enum):
27
+ """Backend type for session management.
28
+
29
+ Attributes:
30
+ TMUX: Use tmux for session management (dev/local).
31
+ CONTAINER: Use Podman/Docker for session management (prod).
32
+ """
33
+
34
+ TMUX = "tmux"
35
+ CONTAINER = "container"
36
+
37
+
38
+ class SessionState(str, Enum):
39
+ """State of a session or container.
40
+
41
+ Attributes:
42
+ RUNNING: Session is active and running.
43
+ STOPPED: Session was stopped gracefully.
44
+ EXITED: Container exited (with exit code).
45
+ DEFINED: Session exists in config but has not been started.
46
+ UNKNOWN: State cannot be determined.
47
+ """
48
+
49
+ RUNNING = "running"
50
+ STOPPED = "stopped"
51
+ EXITED = "exited"
52
+ DEFINED = "defined"
53
+ UNKNOWN = "unknown"
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class SessionConfig:
58
+ """Configuration for creating a session.
59
+
60
+ This dataclass holds all configuration options for both tmux and container
61
+ backends. Options that are not applicable to the selected backend are ignored.
62
+
63
+ Attributes:
64
+ name: Unique session name (required).
65
+ backend: Backend type (tmux or container).
66
+ command: Command to run in the session (tmux) or container.
67
+ working_dir: Working directory for the session.
68
+ env: Environment variables to set.
69
+ image: Container image to use (container backend only).
70
+ volumes: Volume mounts in "host:container" format.
71
+ ports: Port mappings in "host:container" format.
72
+ runtime: Container runtime to use ("podman" or "docker").
73
+ log_volume: Log volume mount for persistence (auto-mounted).
74
+
75
+ Examples:
76
+ >>> config = SessionConfig(
77
+ ... name="astro",
78
+ ... backend=BackendType.TMUX,
79
+ ... command="python -m astro.bot",
80
+ ... )
81
+
82
+ >>> config = SessionConfig(
83
+ ... name="astro-prod",
84
+ ... backend=BackendType.CONTAINER,
85
+ ... image="astro-bot:latest",
86
+ ... volumes=["./data:/app/data"],
87
+ ... log_volume="./logs:/app/logs",
88
+ ... )
89
+ """
90
+
91
+ name: str
92
+ backend: BackendType = BackendType.TMUX
93
+ # Common options
94
+ command: str | None = None
95
+ working_dir: str | None = None
96
+ env: dict[str, str] = field(default_factory=dict)
97
+ # Container-specific options
98
+ image: str | None = None
99
+ volumes: list[str] = field(default_factory=list)
100
+ ports: list[str] = field(default_factory=list)
101
+ runtime: str | None = None
102
+ # Log persistence (post-mortem analysis)
103
+ log_volume: str | None = None
104
+
105
+ def __post_init__(self) -> None:
106
+ """Validate configuration values.
107
+
108
+ Raises:
109
+ ValueError: If any configuration value is invalid.
110
+ """
111
+ validate_session_name(self.name)
112
+ if self.command is not None:
113
+ validate_command(self.command)
114
+ if self.image is not None:
115
+ validate_image_name(self.image)
116
+ if self.volumes:
117
+ validate_volumes(self.volumes)
118
+ if self.ports:
119
+ validate_ports(self.ports)
120
+ if self.env:
121
+ validate_env(self.env)
122
+
123
+
124
+ @dataclass(slots=True)
125
+ class SessionStatus:
126
+ """Current status of a session or container.
127
+
128
+ This dataclass holds the runtime status information for a session,
129
+ including state, PID, creation time, and backend-specific details.
130
+
131
+ Attributes:
132
+ name: Session name.
133
+ state: Current state (running, stopped, exited, unknown).
134
+ backend: Backend type used for this session.
135
+ pid: Process ID (tmux server PID or container main PID).
136
+ created_at: ISO timestamp when the session was created.
137
+ window_count: Number of tmux windows (tmux backend only).
138
+ image: Container image name (container backend only).
139
+ exit_code: Container exit code if exited (container backend only).
140
+
141
+ Examples:
142
+ >>> status = SessionStatus(
143
+ ... name="astro",
144
+ ... state=SessionState.RUNNING,
145
+ ... backend=BackendType.TMUX,
146
+ ... pid=12345,
147
+ ... window_count=1,
148
+ ... )
149
+
150
+ >>> status = SessionStatus(
151
+ ... name="astro-prod",
152
+ ... state=SessionState.RUNNING,
153
+ ... backend=BackendType.CONTAINER,
154
+ ... pid=67890,
155
+ ... image="astro-bot:latest",
156
+ ... )
157
+ """
158
+
159
+ name: str
160
+ state: SessionState
161
+ backend: BackendType
162
+ pid: int | None = None
163
+ created_at: str | None = None
164
+ # tmux-specific
165
+ window_count: int = 0
166
+ # container-specific
167
+ image: str | None = None
168
+ exit_code: int | None = None
169
+
170
+
171
+ __all__ = [
172
+ "BackendType",
173
+ "SessionConfig",
174
+ "SessionState",
175
+ "SessionStatus",
176
+ ]
kstlib/ops/tmux.py ADDED
@@ -0,0 +1,372 @@
1
+ """tmux session runner for local development.
2
+
3
+ This module provides the TmuxRunner class for managing tmux sessions,
4
+ enabling detach/attach workflows for local development and backtesting.
5
+
6
+ Features:
7
+ - Create named sessions with custom commands and working directories
8
+ - Attach to running sessions (replaces current process)
9
+ - Capture session logs with ANSI codes preserved
10
+ - List all sessions with status information
11
+
12
+ Example:
13
+ >>> from kstlib.ops import SessionConfig, BackendType
14
+ >>> from kstlib.ops.tmux import TmuxRunner
15
+ >>> runner = TmuxRunner() # doctest: +SKIP
16
+ >>> config = SessionConfig( # doctest: +SKIP
17
+ ... name="dev",
18
+ ... backend=BackendType.TMUX,
19
+ ... command="python app.py",
20
+ ... )
21
+ >>> status = runner.start(config) # doctest: +SKIP
22
+ >>> runner.attach("dev") # Replaces process # doctest: +SKIP
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import os
29
+ import shutil
30
+ import subprocess
31
+ from typing import TYPE_CHECKING
32
+
33
+ from kstlib.ops.exceptions import (
34
+ SessionAttachError,
35
+ SessionExistsError,
36
+ SessionNotFoundError,
37
+ SessionStartError,
38
+ SessionStopError,
39
+ TmuxNotFoundError,
40
+ )
41
+ from kstlib.ops.models import BackendType, SessionConfig, SessionState, SessionStatus
42
+
43
+ if TYPE_CHECKING:
44
+ from collections.abc import Sequence
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ class TmuxRunner:
50
+ """tmux session runner for local development.
51
+
52
+ Manages tmux sessions for running persistent processes with
53
+ detach/attach capability.
54
+
55
+ Args:
56
+ binary: Path or name of the tmux binary.
57
+
58
+ Attributes:
59
+ binary: The tmux binary path (validated on first use).
60
+
61
+ Examples:
62
+ >>> runner = TmuxRunner() # doctest: +SKIP
63
+ >>> config = SessionConfig(name="bot", command="python bot.py")
64
+ >>> status = runner.start(config) # doctest: +SKIP
65
+ >>> runner.attach("bot") # doctest: +SKIP
66
+ """
67
+
68
+ def __init__(self, binary: str = "tmux") -> None:
69
+ """Initialize TmuxRunner.
70
+
71
+ Args:
72
+ binary: Path or name of the tmux binary.
73
+ """
74
+ self._binary_name = binary
75
+ self._binary_path: str | None = None
76
+
77
+ @property
78
+ def binary(self) -> str:
79
+ """Return validated tmux binary path.
80
+
81
+ Raises:
82
+ TmuxNotFoundError: If tmux is not installed.
83
+ """
84
+ if self._binary_path is None:
85
+ path = shutil.which(self._binary_name)
86
+ if path is None:
87
+ raise TmuxNotFoundError(
88
+ f"tmux binary '{self._binary_name}' not found in PATH. "
89
+ "Install tmux: brew install tmux (macOS), apt install tmux (Linux)"
90
+ )
91
+ self._binary_path = path
92
+ return self._binary_path
93
+
94
+ def _run(
95
+ self,
96
+ args: Sequence[str],
97
+ *,
98
+ check: bool = False,
99
+ ) -> subprocess.CompletedProcess[str]:
100
+ """Run a tmux command.
101
+
102
+ Args:
103
+ args: Command arguments (without binary name).
104
+ check: Whether to raise on non-zero exit.
105
+
106
+ Returns:
107
+ CompletedProcess with stdout/stderr.
108
+
109
+ Raises:
110
+ TmuxNotFoundError: If tmux is not installed.
111
+ """
112
+ cmd = [self.binary, *args]
113
+ logger.debug("Running: %s", " ".join(cmd))
114
+ return subprocess.run( # noqa: S603
115
+ cmd,
116
+ capture_output=True,
117
+ text=True,
118
+ check=check,
119
+ )
120
+
121
+ def start(self, config: SessionConfig) -> SessionStatus:
122
+ """Create and start a new tmux session.
123
+
124
+ Args:
125
+ config: Session configuration.
126
+
127
+ Returns:
128
+ SessionStatus with state information.
129
+
130
+ Raises:
131
+ SessionExistsError: If session already exists.
132
+ SessionStartError: If session failed to start.
133
+ TmuxNotFoundError: If tmux is not installed.
134
+ """
135
+ if self.exists(config.name):
136
+ raise SessionExistsError(config.name, "tmux")
137
+
138
+ # Build command: tmux new-session -d -s {name} [-c {dir}] [command]
139
+ args = ["new-session", "-d", "-s", config.name]
140
+
141
+ if config.working_dir:
142
+ args.extend(["-c", config.working_dir])
143
+
144
+ # Environment variables
145
+ for key, value in config.env.items():
146
+ args.extend(["-e", f"{key}={value}"])
147
+
148
+ if config.command:
149
+ args.append(config.command)
150
+
151
+ result = self._run(args)
152
+
153
+ if result.returncode != 0:
154
+ raise SessionStartError(
155
+ config.name,
156
+ "tmux",
157
+ result.stderr.strip() or "Unknown error",
158
+ )
159
+
160
+ logger.info("Started tmux session: %s", config.name)
161
+ return self.status(config.name)
162
+
163
+ def stop(
164
+ self,
165
+ name: str,
166
+ *,
167
+ graceful: bool = True,
168
+ timeout: int = 10, # noqa: ARG002
169
+ ) -> bool:
170
+ """Stop a tmux session.
171
+
172
+ Args:
173
+ name: Session name to stop.
174
+ graceful: If True, send C-c first, then kill if needed.
175
+ timeout: Unused for tmux (interface compliance with AbstractRunner).
176
+
177
+ Returns:
178
+ True if stopped, False if not running.
179
+
180
+ Raises:
181
+ SessionNotFoundError: If session doesn't exist.
182
+ SessionStopError: If session couldn't be stopped.
183
+ """
184
+ if not self.exists(name):
185
+ raise SessionNotFoundError(name, "tmux")
186
+
187
+ if graceful:
188
+ # Send interrupt signal first
189
+ self._run(["send-keys", "-t", name, "C-c"])
190
+ # Small delay is handled by tmux itself
191
+
192
+ # Kill the session
193
+ result = self._run(["kill-session", "-t", name])
194
+
195
+ if result.returncode != 0:
196
+ # Session may have already exited
197
+ if not self.exists(name):
198
+ logger.info("tmux session already stopped: %s", name)
199
+ return True
200
+ raise SessionStopError(
201
+ name,
202
+ "tmux",
203
+ result.stderr.strip() or "Unknown error",
204
+ )
205
+
206
+ logger.info("Stopped tmux session: %s", name)
207
+ return True
208
+
209
+ def attach(self, name: str) -> None:
210
+ """Attach to a tmux session.
211
+
212
+ This method replaces the current process with tmux attach.
213
+ It does not return on success.
214
+
215
+ Args:
216
+ name: Session name to attach to.
217
+
218
+ Raises:
219
+ SessionNotFoundError: If session doesn't exist.
220
+ SessionAttachError: If attach failed.
221
+ """
222
+ if not self.exists(name):
223
+ raise SessionNotFoundError(name, "tmux")
224
+
225
+ binary = self.binary
226
+ logger.info("Attaching to tmux session: %s", name)
227
+
228
+ # Replace current process with tmux attach
229
+ try:
230
+ os.execvp(binary, [binary, "attach-session", "-t", name]) # noqa: S606
231
+ except OSError as e:
232
+ raise SessionAttachError(name, "tmux", str(e)) from e
233
+
234
+ def status(self, name: str) -> SessionStatus:
235
+ """Get status of a tmux session.
236
+
237
+ Args:
238
+ name: Session name to query.
239
+
240
+ Returns:
241
+ SessionStatus with current state.
242
+
243
+ Raises:
244
+ SessionNotFoundError: If session doesn't exist.
245
+ """
246
+ # List format: #{session_name}:#{window_count}:#{session_created}:#{pid}
247
+ result = self._run(
248
+ [
249
+ "list-sessions",
250
+ "-F",
251
+ "#{session_name}:#{session_windows}:#{session_created}:#{pid}",
252
+ ]
253
+ )
254
+
255
+ if result.returncode != 0:
256
+ if "no server running" in result.stderr:
257
+ raise SessionNotFoundError(name, "tmux")
258
+ raise SessionNotFoundError(name, "tmux")
259
+
260
+ for line in result.stdout.strip().split("\n"):
261
+ if not line:
262
+ continue
263
+ parts = line.split(":")
264
+ if len(parts) >= 4 and parts[0] == name:
265
+ return SessionStatus(
266
+ name=name,
267
+ state=SessionState.RUNNING,
268
+ backend=BackendType.TMUX,
269
+ pid=int(parts[3]) if parts[3].isdigit() else None,
270
+ created_at=parts[2] if parts[2] else None,
271
+ window_count=int(parts[1]) if parts[1].isdigit() else 0,
272
+ )
273
+
274
+ raise SessionNotFoundError(name, "tmux")
275
+
276
+ def logs(self, name: str, lines: int = 100) -> str:
277
+ """Capture recent output from a tmux session.
278
+
279
+ Args:
280
+ name: Session name to get logs from.
281
+ lines: Number of lines to capture.
282
+
283
+ Returns:
284
+ String with captured output (ANSI codes preserved).
285
+
286
+ Raises:
287
+ SessionNotFoundError: If session doesn't exist.
288
+ """
289
+ if not self.exists(name):
290
+ raise SessionNotFoundError(name, "tmux")
291
+
292
+ # capture-pane -t {name} -p -S -{lines}
293
+ result = self._run(["capture-pane", "-t", name, "-p", "-S", f"-{lines}"])
294
+
295
+ if result.returncode != 0:
296
+ return ""
297
+
298
+ return result.stdout
299
+
300
+ def exists(self, name: str) -> bool:
301
+ """Check if a tmux session exists.
302
+
303
+ Args:
304
+ name: Session name to check.
305
+
306
+ Returns:
307
+ True if session exists, False otherwise.
308
+ """
309
+ result = self._run(["has-session", "-t", name])
310
+ return result.returncode == 0
311
+
312
+ def list_sessions(self) -> list[SessionStatus]:
313
+ """List all tmux sessions.
314
+
315
+ Returns:
316
+ List of SessionStatus for all sessions.
317
+ """
318
+ result = self._run(
319
+ [
320
+ "list-sessions",
321
+ "-F",
322
+ "#{session_name}:#{session_windows}:#{session_created}:#{pid}",
323
+ ]
324
+ )
325
+
326
+ if result.returncode != 0:
327
+ # No server running or no sessions
328
+ return []
329
+
330
+ sessions: list[SessionStatus] = []
331
+ for line in result.stdout.strip().split("\n"):
332
+ if not line:
333
+ continue
334
+ parts = line.split(":")
335
+ if len(parts) >= 4:
336
+ sessions.append(
337
+ SessionStatus(
338
+ name=parts[0],
339
+ state=SessionState.RUNNING,
340
+ backend=BackendType.TMUX,
341
+ pid=int(parts[3]) if parts[3].isdigit() else None,
342
+ created_at=parts[2] if parts[2] else None,
343
+ window_count=int(parts[1]) if parts[1].isdigit() else 0,
344
+ )
345
+ )
346
+
347
+ return sessions
348
+
349
+ def send_keys(self, name: str, keys: str, *, enter: bool = True) -> None:
350
+ """Send keys to a tmux session.
351
+
352
+ Args:
353
+ name: Session name to send keys to.
354
+ keys: Keys or text to send.
355
+ enter: If True, send Enter key after the text.
356
+
357
+ Raises:
358
+ SessionNotFoundError: If session doesn't exist.
359
+ """
360
+ if not self.exists(name):
361
+ raise SessionNotFoundError(name, "tmux")
362
+
363
+ args = ["send-keys", "-t", name, keys]
364
+ if enter:
365
+ args.append("Enter")
366
+
367
+ self._run(args)
368
+
369
+
370
+ __all__ = [
371
+ "TmuxRunner",
372
+ ]