shell-session-manager 1.0.1__tar.gz → 2.1.0__tar.gz

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 (31) hide show
  1. shell_session_manager-1.0.1/README.md → shell_session_manager-2.1.0/PKG-INFO +31 -0
  2. shell_session_manager-1.0.1/PKG-INFO → shell_session_manager-2.1.0/README.md +13 -11
  3. {shell_session_manager-1.0.1 → shell_session_manager-2.1.0}/pyproject.toml +11 -1
  4. {shell_session_manager-1.0.1 → shell_session_manager-2.1.0}/src/shell_session_manager/__init__.py +4 -0
  5. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/__init__.py +69 -0
  6. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/client/__init__.py +12 -0
  7. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/client/sdk.py +264 -0
  8. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/server/__init__.py +33 -0
  9. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/server/__main__.py +6 -0
  10. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/server/api.py +204 -0
  11. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/server/cli.py +112 -0
  12. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/server/config.py +104 -0
  13. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/server/db.py +52 -0
  14. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/server/errors.py +14 -0
  15. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/server/service.py +1022 -0
  16. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/server/tmux.py +296 -0
  17. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/shared/__init__.py +117 -0
  18. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/shared/constants.py +44 -0
  19. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/shared/output.py +120 -0
  20. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/shared/runtime.py +97 -0
  21. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/shared/sanitize.py +139 -0
  22. shell_session_manager-2.1.0/src/shell_session_manager/shellctl/shared/schemas.py +195 -0
  23. shell_session_manager-2.1.0/tests/golden_shellctl_sanitize.json +32 -0
  24. shell_session_manager-2.1.0/tests/test_shellctl_client.py +102 -0
  25. shell_session_manager-2.1.0/tests/test_shellctl_service.py +1133 -0
  26. shell_session_manager-2.1.0/tests/test_shellctl_shared.py +114 -0
  27. {shell_session_manager-1.0.1 → shell_session_manager-2.1.0}/LICENSE +0 -0
  28. {shell_session_manager-1.0.1 → shell_session_manager-2.1.0}/src/shell_session_manager/py.typed +0 -0
  29. {shell_session_manager-1.0.1 → shell_session_manager-2.1.0}/src/shell_session_manager/session.py +0 -0
  30. {shell_session_manager-1.0.1 → shell_session_manager-2.1.0}/tests/test_shell_session_autoclose.py +0 -0
  31. {shell_session_manager-1.0.1 → shell_session_manager-2.1.0}/tests/test_shell_session_manager.py +0 -0
@@ -1,3 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: shell-session-manager
3
+ Version: 2.1.0
4
+ Summary: Async subprocess session manager with incremental stdin/stdout/stderr support
5
+ Author-Email: =?utf-8?b?WWFubGkg55uQ57KS?= <yanli@mail.one>
6
+ License-Expression: Apache-2.0
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: aiosqlite>=0.21.0
10
+ Requires-Dist: anyio>=4.12.1
11
+ Requires-Dist: fastapi>=0.116.1
12
+ Requires-Dist: httpx>=0.28.1
13
+ Requires-Dist: pydantic>=2.12.5
14
+ Requires-Dist: sqlmodel>=0.0.24
15
+ Requires-Dist: typer>=0.16.1
16
+ Requires-Dist: uvicorn>=0.35.0
17
+ Description-Content-Type: text/markdown
18
+
1
19
  # shell-session-manager
2
20
 
3
21
  `shell-session-manager` provides async subprocess sessions that can be resumed
@@ -49,3 +67,16 @@ async def main() -> None:
49
67
 
50
68
  anyio.run(main)
51
69
  ```
70
+
71
+ ## shellctl server
72
+
73
+ Run the HTTP API locally with:
74
+
75
+ ```bash
76
+ pdm run shellctl serve --listen 127.0.0.1:8765
77
+ ```
78
+
79
+ Pass `--auth-token your-token` when you want bearer auth enforced. `shellctl
80
+ serve` also reads `SHELLCTL_AUTH_TOKEN`, so you can export the token instead of
81
+ passing the flag. Leave the flag/env var unset or empty to start without
82
+ requiring an Authorization header.
@@ -1,14 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: shell-session-manager
3
- Version: 1.0.1
4
- Summary: Async subprocess session manager with incremental stdin/stdout/stderr support
5
- Author-Email: =?utf-8?b?WWFubGkg55uQ57KS?= <yanli@mail.one>
6
- License-Expression: Apache-2.0
7
- License-File: LICENSE
8
- Requires-Python: >=3.12
9
- Requires-Dist: anyio>=4.12.1
10
- Description-Content-Type: text/markdown
11
-
12
1
  # shell-session-manager
13
2
 
14
3
  `shell-session-manager` provides async subprocess sessions that can be resumed
@@ -60,3 +49,16 @@ async def main() -> None:
60
49
 
61
50
  anyio.run(main)
62
51
  ```
52
+
53
+ ## shellctl server
54
+
55
+ Run the HTTP API locally with:
56
+
57
+ ```bash
58
+ pdm run shellctl serve --listen 127.0.0.1:8765
59
+ ```
60
+
61
+ Pass `--auth-token your-token` when you want bearer auth enforced. `shellctl
62
+ serve` also reads `SHELLCTL_AUTH_TOKEN`, so you can export the token instead of
63
+ passing the flag. Leave the flag/env var unset or empty to start without
64
+ requiring an Authorization header.
@@ -6,13 +6,20 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "shell-session-manager"
9
- version = "1.0.1"
9
+ version = "2.1.0"
10
10
  description = "Async subprocess session manager with incremental stdin/stdout/stderr support"
11
11
  authors = [
12
12
  { name = "Yanli 盐粒", email = "yanli@mail.one" },
13
13
  ]
14
14
  dependencies = [
15
+ "aiosqlite>=0.21.0",
15
16
  "anyio>=4.12.1",
17
+ "fastapi>=0.116.1",
18
+ "httpx>=0.28.1",
19
+ "pydantic>=2.12.5",
20
+ "sqlmodel>=0.0.24",
21
+ "typer>=0.16.1",
22
+ "uvicorn>=0.35.0",
16
23
  ]
17
24
  requires-python = ">=3.12"
18
25
  readme = "README.md"
@@ -21,6 +28,9 @@ license-files = [
21
28
  "LICENSE",
22
29
  ]
23
30
 
31
+ [project.scripts]
32
+ shellctl = "shell_session_manager.shellctl.server:main"
33
+
24
34
  [dependency-groups]
25
35
  dev = [
26
36
  "pytest>=9.0.2",
@@ -4,6 +4,10 @@ The package exports the session primitives from `shell_session_manager.session`.
4
4
  Callers are responsible for constructing the command string; this package only
5
5
  owns process lifetime, incremental stdin writes, stdout/stderr draining, and
6
6
  session registry management.
7
+
8
+ The higher-level HTTP/tmux shellctl implementation lives under
9
+ `shell_session_manager.shellctl` so projects that only need in-process shell
10
+ sessions do not need to import the networked manager.
7
11
  """
8
12
 
9
13
  from shell_session_manager.session import (
@@ -0,0 +1,69 @@
1
+ """Public shellctl package exports.
2
+
3
+ The client/server/shared entry points now live in package subfolders so each can
4
+ contain smaller responsibility-focused files while preserving the original
5
+ import paths through package `__init__` re-exports.
6
+ """
7
+
8
+ from shell_session_manager.shellctl.client import ShellctlClient, ShellctlClientError
9
+ from shell_session_manager.shellctl.shared import (
10
+ DEFAULT_AUTH_TOKEN_ENV,
11
+ DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
12
+ DEFAULT_GC_INTERVAL_SECONDS,
13
+ DEFAULT_IDLE_FLUSH_SECONDS,
14
+ DEFAULT_LIST_LIMIT,
15
+ DEFAULT_OUTPUT_LIMIT_BYTES,
16
+ DEFAULT_TERMINAL_COLS,
17
+ DEFAULT_TERMINAL_ROWS,
18
+ DEFAULT_TERMINATE_GRACE_SECONDS,
19
+ DEFAULT_TIMEOUT_SECONDS,
20
+ DeleteJobResponse,
21
+ HealthResponse,
22
+ InputJobRequest,
23
+ JobInfo,
24
+ JobResult,
25
+ JobStatusName,
26
+ JobStatusView,
27
+ ListJobsResponse,
28
+ PtySanitizer,
29
+ RunJobRequest,
30
+ TerminalSize,
31
+ TerminateJobRequest,
32
+ WaitJobRequest,
33
+ generate_job_id,
34
+ read_output_window,
35
+ sanitize_pty_output,
36
+ tail_output_window,
37
+ )
38
+
39
+ __all__ = [
40
+ "DEFAULT_AUTH_TOKEN_ENV",
41
+ "DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS",
42
+ "DEFAULT_GC_INTERVAL_SECONDS",
43
+ "DEFAULT_IDLE_FLUSH_SECONDS",
44
+ "DEFAULT_LIST_LIMIT",
45
+ "DEFAULT_OUTPUT_LIMIT_BYTES",
46
+ "DEFAULT_TERMINAL_COLS",
47
+ "DEFAULT_TERMINAL_ROWS",
48
+ "DEFAULT_TERMINATE_GRACE_SECONDS",
49
+ "DEFAULT_TIMEOUT_SECONDS",
50
+ "DeleteJobResponse",
51
+ "HealthResponse",
52
+ "InputJobRequest",
53
+ "JobInfo",
54
+ "JobResult",
55
+ "JobStatusName",
56
+ "JobStatusView",
57
+ "ListJobsResponse",
58
+ "PtySanitizer",
59
+ "RunJobRequest",
60
+ "ShellctlClient",
61
+ "ShellctlClientError",
62
+ "TerminalSize",
63
+ "TerminateJobRequest",
64
+ "WaitJobRequest",
65
+ "generate_job_id",
66
+ "read_output_window",
67
+ "sanitize_pty_output",
68
+ "tail_output_window",
69
+ ]
@@ -0,0 +1,12 @@
1
+ """Async HTTP client package for shellctl.
2
+
3
+ `shell_session_manager.shellctl.client` remains importable as before, but it is
4
+ now a package so future client helpers can live beside the main SDK class.
5
+ """
6
+
7
+ from shell_session_manager.shellctl.client.sdk import (
8
+ ShellctlClient,
9
+ ShellctlClientError,
10
+ )
11
+
12
+ __all__ = ["ShellctlClient", "ShellctlClientError"]
@@ -0,0 +1,264 @@
1
+ """Async HTTP client for the shellctl server API.
2
+
3
+ The SDK keeps transport-level knobs (`output_limit`, `idle_flush_seconds`, and
4
+ bearer token handling) on the client instance so individual method calls stay
5
+ close to the proposal's high-level workflow.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from shell_session_manager.shellctl.shared import (
16
+ DEFAULT_AUTH_TOKEN_ENV,
17
+ DEFAULT_IDLE_FLUSH_SECONDS,
18
+ DEFAULT_LIST_LIMIT,
19
+ DEFAULT_OUTPUT_LIMIT_BYTES,
20
+ DEFAULT_TERMINATE_GRACE_SECONDS,
21
+ DEFAULT_TIMEOUT_SECONDS,
22
+ DeleteJobResponse,
23
+ JobInfo,
24
+ JobResult,
25
+ JobStatusView,
26
+ ListJobsResponse,
27
+ RunJobRequest,
28
+ TerminalSize,
29
+ )
30
+
31
+
32
+ class ShellctlClientError(RuntimeError):
33
+ """Raised when the shellctl server returns an error response."""
34
+
35
+ def __init__(self, status_code: int, code: str, message: str) -> None:
36
+ super().__init__(f"{code} ({status_code}): {message}")
37
+ self.status_code = status_code
38
+ self.code = code
39
+ self.message = message
40
+
41
+
42
+ class ShellctlClient:
43
+ """Thin async SDK for the shellctl HTTP API.
44
+
45
+ The client owns a reusable `httpx.AsyncClient` unless one is injected via the
46
+ `client` argument. Callers can therefore either keep one instance for a full
47
+ workflow or treat it as an async context manager.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ base_url: str,
53
+ *,
54
+ output_limit: int = DEFAULT_OUTPUT_LIMIT_BYTES,
55
+ idle_flush_seconds: float = DEFAULT_IDLE_FLUSH_SECONDS,
56
+ token: str | None = None,
57
+ client: httpx.AsyncClient | None = None,
58
+ transport: httpx.AsyncBaseTransport | None = None,
59
+ ) -> None:
60
+ self.base_url = base_url.rstrip("/")
61
+ self.output_limit = output_limit
62
+ self.idle_flush_seconds = idle_flush_seconds
63
+ self.token = (
64
+ token if token is not None else os.environ.get(DEFAULT_AUTH_TOKEN_ENV)
65
+ )
66
+ self._owns_client = client is None
67
+ self._client = client or httpx.AsyncClient(
68
+ base_url=self.base_url,
69
+ follow_redirects=True,
70
+ timeout=httpx.Timeout(
71
+ DEFAULT_TIMEOUT_SECONDS, connect=DEFAULT_TIMEOUT_SECONDS
72
+ ),
73
+ transport=transport,
74
+ )
75
+
76
+ async def __aenter__(self) -> ShellctlClient:
77
+ return self
78
+
79
+ async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
80
+ await self.close()
81
+
82
+ async def close(self) -> None:
83
+ """Close the underlying HTTP client if this SDK instance owns it."""
84
+
85
+ if self._owns_client:
86
+ await self._client.aclose()
87
+
88
+ async def healthz(self) -> dict[str, Any]:
89
+ """Call the public health endpoint without requiring auth."""
90
+
91
+ response = await self._client.get("/healthz")
92
+ return self._decode_response(response)
93
+
94
+ async def run(
95
+ self,
96
+ script: str,
97
+ *,
98
+ cwd: str | None = None,
99
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
100
+ terminal: TerminalSize | None = None,
101
+ ) -> JobResult:
102
+ """Create a new job and wait for initial output or completion."""
103
+
104
+ payload = RunJobRequest(
105
+ script=script,
106
+ cwd=cwd,
107
+ terminal=terminal,
108
+ timeout=timeout,
109
+ output_limit=self.output_limit,
110
+ idle_flush_seconds=self.idle_flush_seconds,
111
+ )
112
+ response = await self._client.post(
113
+ "/v1/jobs/run",
114
+ json=payload.model_dump(mode="json", exclude_none=True),
115
+ headers=self._auth_headers(),
116
+ )
117
+ return JobResult.model_validate(self._decode_response(response))
118
+
119
+ async def wait(
120
+ self,
121
+ job_id: str,
122
+ *,
123
+ offset: int,
124
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
125
+ ) -> JobResult:
126
+ """Wait for incremental output, completion, truncation, or timeout."""
127
+
128
+ response = await self._client.post(
129
+ f"/v1/jobs/{job_id}/wait",
130
+ json={
131
+ "offset": offset,
132
+ "timeout": timeout,
133
+ "output_limit": self.output_limit,
134
+ "idle_flush_seconds": self.idle_flush_seconds,
135
+ },
136
+ headers=self._auth_headers(),
137
+ )
138
+ return JobResult.model_validate(self._decode_response(response))
139
+
140
+ async def status(self, job_id: str) -> JobStatusView:
141
+ """Fetch the materialized status view for one job."""
142
+
143
+ response = await self._client.get(
144
+ f"/v1/jobs/{job_id}",
145
+ headers=self._auth_headers(),
146
+ )
147
+ return JobStatusView.model_validate(self._decode_response(response))
148
+
149
+ async def list_jobs(
150
+ self,
151
+ *,
152
+ status: str | None = None,
153
+ limit: int = DEFAULT_LIST_LIMIT,
154
+ ) -> list[JobInfo]:
155
+ """List recent jobs, optionally filtered by lifecycle status."""
156
+
157
+ params: dict[str, Any] = {"limit": limit}
158
+ if status is not None:
159
+ params["status"] = status
160
+ response = await self._client.get(
161
+ "/v1/jobs",
162
+ params=params,
163
+ headers=self._auth_headers(),
164
+ )
165
+ payload = ListJobsResponse.model_validate(self._decode_response(response))
166
+ return payload.jobs
167
+
168
+ async def input(
169
+ self,
170
+ job_id: str,
171
+ text: str,
172
+ *,
173
+ offset: int,
174
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
175
+ ) -> JobResult:
176
+ """Send text input to a running job and then wait like `wait()`."""
177
+
178
+ response = await self._client.post(
179
+ f"/v1/jobs/{job_id}/input",
180
+ json={
181
+ "text": text,
182
+ "offset": offset,
183
+ "timeout": timeout,
184
+ "output_limit": self.output_limit,
185
+ "idle_flush_seconds": self.idle_flush_seconds,
186
+ },
187
+ headers=self._auth_headers(),
188
+ )
189
+ return JobResult.model_validate(self._decode_response(response))
190
+
191
+ async def tail(self, job_id: str) -> JobResult:
192
+ """Fetch an immediate UTF-8-safe tail snapshot for a job."""
193
+
194
+ response = await self._client.get(
195
+ f"/v1/jobs/{job_id}/log/tail",
196
+ params={"output_limit": self.output_limit},
197
+ headers=self._auth_headers(),
198
+ )
199
+ return JobResult.model_validate(self._decode_response(response))
200
+
201
+ async def terminate(
202
+ self,
203
+ job_id: str,
204
+ grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS,
205
+ ) -> JobStatusView:
206
+ """Terminate a job, returning the resulting materialized status view."""
207
+
208
+ response = await self._client.post(
209
+ f"/v1/jobs/{job_id}/terminate",
210
+ json={"grace_seconds": grace_seconds},
211
+ headers=self._auth_headers(),
212
+ )
213
+ return JobStatusView.model_validate(self._decode_response(response))
214
+
215
+ async def delete(
216
+ self,
217
+ job_id: str,
218
+ *,
219
+ force: bool = False,
220
+ grace_seconds: float | None = None,
221
+ ) -> DeleteJobResponse:
222
+ """Delete job artifacts, optionally terminating the job first."""
223
+
224
+ params: dict[str, Any] = {"force": str(force).lower()}
225
+ if grace_seconds is not None:
226
+ params["grace_seconds"] = grace_seconds
227
+ response = await self._client.delete(
228
+ f"/v1/jobs/{job_id}",
229
+ params=params,
230
+ headers=self._auth_headers(),
231
+ )
232
+ return DeleteJobResponse.model_validate(self._decode_response(response))
233
+
234
+ def _auth_headers(self) -> dict[str, str]:
235
+ if not self.token:
236
+ return {}
237
+ return {"Authorization": f"Bearer {self.token}"}
238
+
239
+ def _decode_response(self, response: httpx.Response) -> dict[str, Any]:
240
+ try:
241
+ payload = response.json()
242
+ except ValueError as exc: # pragma: no cover - network/proxy corruption
243
+ raise ShellctlClientError(
244
+ response.status_code, "invalid_json", response.text
245
+ ) from exc
246
+
247
+ if response.is_error:
248
+ error = payload.get("error") if isinstance(payload, dict) else None
249
+ if isinstance(error, dict):
250
+ code = str(error.get("code", "request_failed"))
251
+ message = str(error.get("message", response.text))
252
+ else:
253
+ code = "request_failed"
254
+ message = response.text
255
+ raise ShellctlClientError(response.status_code, code, message)
256
+
257
+ if not isinstance(payload, dict):
258
+ raise ShellctlClientError(
259
+ response.status_code, "invalid_payload", response.text
260
+ )
261
+ return payload
262
+
263
+
264
+ __all__ = ["ShellctlClient", "ShellctlClientError"]
@@ -0,0 +1,33 @@
1
+ """shellctl server package.
2
+
3
+ The original monolithic `shellctl.server` module is now organized as a package
4
+ with smaller files for configuration, SQLite models, tmux control, lifecycle
5
+ service logic, FastAPI wiring, and CLI commands. This `__init__` keeps the
6
+ common public-ish imports stable without re-exporting unrelated internals.
7
+ """
8
+
9
+ from shell_session_manager.shellctl.server.api import create_app
10
+ from shell_session_manager.shellctl.server.cli import (
11
+ cli,
12
+ main,
13
+ runner_exit_command,
14
+ sanitize_pty_command,
15
+ serve_command,
16
+ )
17
+ from shell_session_manager.shellctl.server.config import ShellctlConfig
18
+ from shell_session_manager.shellctl.server.db import JobRow
19
+ from shell_session_manager.shellctl.server.errors import ShellctlServerError
20
+ from shell_session_manager.shellctl.server.service import ShellctlService
21
+
22
+ __all__ = [
23
+ "JobRow",
24
+ "ShellctlConfig",
25
+ "ShellctlServerError",
26
+ "ShellctlService",
27
+ "cli",
28
+ "create_app",
29
+ "main",
30
+ "runner_exit_command",
31
+ "sanitize_pty_command",
32
+ "serve_command",
33
+ ]
@@ -0,0 +1,6 @@
1
+ """Support `python -m shell_session_manager.shellctl.server`."""
2
+
3
+ from shell_session_manager.shellctl.server.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()