sessionbin 0.0.1__tar.gz → 0.2.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.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: sessionbin
3
+ Version: 0.2.0
4
+ Summary: CLI for uploading and managing sessionbin transcripts
5
+ Author: Martin Prpič
6
+ Author-email: Martin Prpič <martin.prpic@gmail.com>
7
+ License-Expression: MIT
8
+ Requires-Dist: click>=8.1
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: platformdirs>=4.3
11
+ Requires-Dist: questionary>=2.1
12
+ Requires-Python: >=3.10
13
+ Project-URL: Homepage, https://github.com/sessionbin/sessionbin
14
+ Project-URL: Repository, https://github.com/sessionbin/sessionbin
15
+ Description-Content-Type: text/markdown
16
+
17
+ # sessionbin CLI
18
+
19
+ CLI for uploading and managing [sessionbin](https://sessionbin.dev) transcripts.
20
+
21
+ ## Supported harnesses
22
+
23
+ **Claude Code** sessions are read as files from `~/.claude/projects/`.
24
+
25
+ **OpenCode** sessions live in a local SQLite database rather than as files, so uploading
26
+ one shells out to `opencode export`. The `opencode` binary must be on your `PATH` (or at
27
+ `~/.opencode/bin/opencode`).
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ uv tool install sessionbin
33
+ # or
34
+ pip install --user sessionbin
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### Upload a session
40
+
41
+ Upload a specific file:
42
+
43
+ ```bash
44
+ sessionbin upload ~/.claude/projects/-home-user-repos-myproject/abc123.jsonl
45
+ ```
46
+
47
+ Or pick from recent sessions interactively (Claude Code and OpenCode):
48
+
49
+ ```bash
50
+ sessionbin upload
51
+ ```
52
+
53
+ Upload the most recent session directly:
54
+
55
+ ```bash
56
+ sessionbin upload --latest
57
+ ```
58
+
59
+ Use `-y` to skip the confirmation prompt:
60
+
61
+ ```bash
62
+ sessionbin upload --latest -y
63
+ ```
64
+
65
+ ### List uploads
66
+
67
+ ```bash
68
+ sessionbin list
69
+ ```
70
+
71
+ Shows locally-tracked uploads (URL, filename, upload time).
72
+
73
+ ### Delete an upload
74
+
75
+ ```bash
76
+ sessionbin delete <slug>
77
+ ```
78
+
79
+ Deletes the paste on the server and removes the local record.
80
+
81
+ ## Configuration
82
+
83
+ On first run, you'll be prompted for a server URL (defaults to `https://sessionbin.dev`). The choice is saved to `~/.config/sessionbin/config.toml`.
84
+
85
+ You can override the server URL per-command:
86
+
87
+ ```bash
88
+ sessionbin upload --server http://localhost:8000
89
+ ```
90
+
91
+ Or via environment variable:
92
+
93
+ ```bash
94
+ export SESSIONBIN_URL=http://localhost:8000
95
+ ```
96
+
97
+ Priority: `--server` flag > `SESSIONBIN_URL` env var > `config.toml` > interactive prompt.
98
+
99
+ ## Session data
100
+
101
+ Upload metadata (slugs, delete tokens) is stored in `~/.config/sessionbin/sessions.json` with `0600` permissions. This file is local-only — the `list` command reads from it without contacting the server.
@@ -0,0 +1,85 @@
1
+ # sessionbin CLI
2
+
3
+ CLI for uploading and managing [sessionbin](https://sessionbin.dev) transcripts.
4
+
5
+ ## Supported harnesses
6
+
7
+ **Claude Code** sessions are read as files from `~/.claude/projects/`.
8
+
9
+ **OpenCode** sessions live in a local SQLite database rather than as files, so uploading
10
+ one shells out to `opencode export`. The `opencode` binary must be on your `PATH` (or at
11
+ `~/.opencode/bin/opencode`).
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ uv tool install sessionbin
17
+ # or
18
+ pip install --user sessionbin
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ### Upload a session
24
+
25
+ Upload a specific file:
26
+
27
+ ```bash
28
+ sessionbin upload ~/.claude/projects/-home-user-repos-myproject/abc123.jsonl
29
+ ```
30
+
31
+ Or pick from recent sessions interactively (Claude Code and OpenCode):
32
+
33
+ ```bash
34
+ sessionbin upload
35
+ ```
36
+
37
+ Upload the most recent session directly:
38
+
39
+ ```bash
40
+ sessionbin upload --latest
41
+ ```
42
+
43
+ Use `-y` to skip the confirmation prompt:
44
+
45
+ ```bash
46
+ sessionbin upload --latest -y
47
+ ```
48
+
49
+ ### List uploads
50
+
51
+ ```bash
52
+ sessionbin list
53
+ ```
54
+
55
+ Shows locally-tracked uploads (URL, filename, upload time).
56
+
57
+ ### Delete an upload
58
+
59
+ ```bash
60
+ sessionbin delete <slug>
61
+ ```
62
+
63
+ Deletes the paste on the server and removes the local record.
64
+
65
+ ## Configuration
66
+
67
+ On first run, you'll be prompted for a server URL (defaults to `https://sessionbin.dev`). The choice is saved to `~/.config/sessionbin/config.toml`.
68
+
69
+ You can override the server URL per-command:
70
+
71
+ ```bash
72
+ sessionbin upload --server http://localhost:8000
73
+ ```
74
+
75
+ Or via environment variable:
76
+
77
+ ```bash
78
+ export SESSIONBIN_URL=http://localhost:8000
79
+ ```
80
+
81
+ Priority: `--server` flag > `SESSIONBIN_URL` env var > `config.toml` > interactive prompt.
82
+
83
+ ## Session data
84
+
85
+ Upload metadata (slugs, delete tokens) is stored in `~/.config/sessionbin/sessions.json` with `0600` permissions. This file is local-only — the `list` command reads from it without contacting the server.
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.0,<0.13.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "sessionbin"
7
+ version = "0.2.0"
8
+ description = "CLI for uploading and managing sessionbin transcripts"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "click>=8.1",
14
+ "httpx>=0.27",
15
+ "platformdirs>=4.3",
16
+ "questionary>=2.1",
17
+ ]
18
+
19
+ [[project.authors]]
20
+ name = "Martin Prpič"
21
+ email = "martin.prpic@gmail.com"
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/sessionbin/sessionbin"
25
+ Repository = "https://github.com/sessionbin/sessionbin"
26
+
27
+ [project.scripts]
28
+ sessionbin = "sessionbin_cli.cli:cli"
29
+
30
+ [tool.uv.build-backend]
31
+ module-name = "sessionbin_cli"
32
+
33
+ [tool.ruff]
34
+ line-length = 100
35
+
36
+ [tool.ruff.lint]
37
+ select = [
38
+ "F",
39
+ "E",
40
+ "W",
41
+ "I",
42
+ "N",
43
+ ]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+
48
+ [tool.mypy]
49
+ python_version = "3.13"
50
+ warn_unused_configs = true
51
+ warn_unreachable = true
52
+ warn_no_return = true
53
+ warn_unused_ignores = true
54
+
55
+ [dependency-groups]
56
+ dev = ["pytest>=8.4.2"]
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.0,<0.13.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "sessionbin"
7
+ version = "0.2.0"
8
+ description = "CLI for uploading and managing sessionbin transcripts"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [
12
+ { name = "Martin Prpič", email = "martin.prpic@gmail.com" }
13
+ ]
14
+ requires-python = ">=3.10"
15
+ dependencies = [
16
+ "click>=8.1",
17
+ "httpx>=0.27",
18
+ "platformdirs>=4.3",
19
+ "questionary>=2.1",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/sessionbin/sessionbin"
24
+ Repository = "https://github.com/sessionbin/sessionbin"
25
+
26
+ [project.scripts]
27
+ sessionbin = "sessionbin_cli.cli:cli"
28
+
29
+ # The distribution is published as `sessionbin`, but the import package is
30
+ # `sessionbin_cli` so it cannot collide with the backend's `sessionbin` package.
31
+ [tool.uv.build-backend]
32
+ module-name = "sessionbin_cli"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "pytest>=8.4.2",
37
+ ]
38
+
39
+ [tool.ruff]
40
+ line-length = 100
41
+
42
+ [tool.ruff.lint]
43
+ # https://docs.astral.sh/ruff/rules/
44
+ select = ["F", "E", "W", "I", "N"]
45
+
46
+ # Present so pytest stops here and does not inherit the backend's Django settings
47
+ # from the repo-root pyproject.toml.
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+
51
+ [tool.mypy]
52
+ python_version = "3.13"
53
+ warn_unused_configs = true
54
+ warn_unreachable = true
55
+ warn_no_return = true
56
+ warn_unused_ignores = true
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+
6
+ class APIError(Exception):
7
+ def __init__(self, status_code: int, message: str) -> None:
8
+ self.status_code = status_code
9
+ self.message = message
10
+ super().__init__(f"HTTP {status_code}: {message}")
11
+
12
+
13
+ class SessionbinClient:
14
+ def __init__(self, base_url: str, timeout: float = 60.0) -> None:
15
+ self.client = httpx.Client(base_url=base_url, timeout=timeout)
16
+
17
+ def upload(self, data: bytes, filename: str, harness: str | None = None) -> dict:
18
+ params = {"harness": harness} if harness else {}
19
+ resp = self.client.post("/api/upload", files={"file": (filename, data)}, params=params)
20
+ if resp.status_code != 200:
21
+ raise self._error(resp)
22
+ return resp.json()
23
+
24
+ def delete(self, slug: str, token: str) -> None:
25
+ resp = self.client.delete(f"/api/p/{slug}", headers={"X-Delete-Token": token})
26
+ if resp.status_code != 204:
27
+ raise self._error(resp)
28
+
29
+ def _error(self, resp: httpx.Response) -> APIError:
30
+ try:
31
+ body = resp.json()
32
+ message = body.get("error") or body.get("detail") or resp.text
33
+ except Exception:
34
+ message = resp.text or resp.reason_phrase
35
+ return APIError(resp.status_code, message)
@@ -0,0 +1,271 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ import click
12
+ import questionary
13
+
14
+ from sessionbin_cli import __version__
15
+ from sessionbin_cli.api import APIError, SessionbinClient
16
+ from sessionbin_cli.config import resolve_server_url
17
+ from sessionbin_cli.detect import SessionInfo, find_all_sessions, most_recent
18
+ from sessionbin_cli.sessions import all as sessions_all
19
+ from sessionbin_cli.sessions import get as session_get
20
+ from sessionbin_cli.sessions import remove as session_remove
21
+ from sessionbin_cli.sessions import save as session_save
22
+
23
+ # Mirrors the default server cap; another server answers 413 with its own.
24
+ MAX_UPLOAD_BYTES = 20 * 1024 * 1024
25
+
26
+ CONTEXT_SETTINGS = {
27
+ "help_option_names": ["-h", "--help"],
28
+ }
29
+
30
+
31
+ def _human_size(nbytes: int) -> str:
32
+ for unit in ("B", "KB", "MB", "GB"):
33
+ if nbytes < 1024:
34
+ return f"{nbytes:.1f} {unit}" if unit != "B" else f"{nbytes} {unit}"
35
+ nbytes /= 1024 # type: ignore[assignment]
36
+ return f"{nbytes:.1f} TB"
37
+
38
+
39
+ def _time_ago(mtime: float) -> str:
40
+ delta = time.time() - mtime
41
+ if delta < 60:
42
+ return "just now"
43
+ if delta < 3600:
44
+ mins = int(delta / 60)
45
+ return f"{mins}m ago"
46
+ if delta < 86400:
47
+ hours = int(delta / 3600)
48
+ return f"{hours}h ago"
49
+ days = int(delta / 86400)
50
+ return f"{days}d ago"
51
+
52
+
53
+ def _short_summary(info: SessionInfo, max_len: int) -> str:
54
+ text = info.title or info.summary or info.path.name
55
+ text = " ".join(text.splitlines())
56
+ if len(text) > max_len:
57
+ text = text[:max_len] + "..."
58
+ return text
59
+
60
+
61
+ def _session_size(s: SessionInfo) -> str:
62
+ if s.harness == "opencode":
63
+ return "-"
64
+ try:
65
+ return _human_size(s.path.stat().st_size)
66
+ except OSError:
67
+ return "-"
68
+
69
+
70
+ DEFAULT_PICKER_LIMIT = 20
71
+
72
+
73
+ def _pick_session(show_all: bool = False) -> SessionInfo | None:
74
+ all_sessions = find_all_sessions()
75
+ sessions = all_sessions if show_all else all_sessions[:DEFAULT_PICKER_LIMIT]
76
+ if not sessions:
77
+ return None
78
+
79
+ sizes = {id(s): _session_size(s) for s in sessions}
80
+ time_w = max(len(_time_ago(s.mtime)) for s in sessions)
81
+ size_w = max(len(v) for v in sizes.values())
82
+ src_w = max(len(s.harness) for s in sessions)
83
+ proj_w = max(len(s.project) for s in sessions)
84
+ summary_max = 60
85
+
86
+ def row(s: SessionInfo) -> str:
87
+ ago = _time_ago(s.mtime)
88
+ summary = _short_summary(s, summary_max)
89
+ return (
90
+ f"{ago:<{time_w}} {sizes[id(s)]:>{size_w}} {s.harness:<{src_w}} "
91
+ f"{s.project:<{proj_w}} {summary}"
92
+ )
93
+
94
+ header = (
95
+ f"{'TIME':<{time_w}} {'SIZE':>{size_w}} {'SOURCE':<{src_w}} "
96
+ f"{'PROJECT':<{proj_w}} SESSION NAME / SUMMARY"
97
+ )
98
+ choices: list[questionary.Choice | questionary.Separator] = [
99
+ questionary.Separator(header),
100
+ *[questionary.Choice(title=row(s), value=s) for s in sessions],
101
+ ]
102
+ result = questionary.select(
103
+ "Select a session to upload:",
104
+ choices=choices,
105
+ style=questionary.Style([("separator", "fg:#00d26a bold")]),
106
+ ).unsafe_ask()
107
+ return result
108
+
109
+
110
+ @click.group(context_settings=CONTEXT_SETTINGS)
111
+ @click.version_option(__version__, "-V", "--version")
112
+ def cli():
113
+ """CLI for uploading and managing sessionbin transcripts."""
114
+
115
+
116
+ @cli.command()
117
+ @click.argument("path", required=False, type=click.Path(exists=True))
118
+ @click.option("--server", default=None, help="Override the server URL.")
119
+ @click.option("-l", "--latest", is_flag=True, help="Upload the most recent session.")
120
+ @click.option("-y", "--yes", is_flag=True, help="Skip the confirmation prompt.")
121
+ @click.option(
122
+ "-a", "--all", "show_all", is_flag=True, help="Show all sessions (default: latest 20)."
123
+ )
124
+ def upload(path: str | None, server: str | None, latest: bool, yes: bool, show_all: bool):
125
+ """Upload a session file."""
126
+ session: SessionInfo | None = None
127
+
128
+ if path is not None:
129
+ upload_data = Path(path).read_bytes()
130
+ upload_filename = Path(path).name
131
+ elif latest:
132
+ session = most_recent()
133
+ if session is None:
134
+ click.secho("No sessions found.", fg="red", err=True)
135
+ sys.exit(1)
136
+ size = _session_size(session)
137
+ ago = _time_ago(session.mtime)
138
+ click.echo(f"Found: {session.path} ({size}, modified {ago})")
139
+ if not yes:
140
+ if not click.confirm("Upload?", default=False):
141
+ raise SystemExit(0)
142
+ if session.harness == "opencode":
143
+ upload_data, upload_filename = _export_opencode_session(session)
144
+ else:
145
+ upload_data, upload_filename = session.path.read_bytes(), session.path.name
146
+ else:
147
+ session = _pick_session(show_all=show_all)
148
+ if session is None:
149
+ click.secho("No sessions found.", fg="red", err=True)
150
+ sys.exit(1)
151
+ if session.harness == "opencode":
152
+ upload_data, upload_filename = _export_opencode_session(session)
153
+ else:
154
+ upload_data, upload_filename = session.path.read_bytes(), session.path.name
155
+
156
+ if len(upload_data) > MAX_UPLOAD_BYTES:
157
+ click.secho(
158
+ f"Upload is {_human_size(len(upload_data))}, which exceeds the "
159
+ f"{_human_size(MAX_UPLOAD_BYTES)} upload limit.",
160
+ fg="red",
161
+ err=True,
162
+ )
163
+ sys.exit(1)
164
+
165
+ server_url = resolve_server_url(server)
166
+ client = SessionbinClient(server_url)
167
+ harness = session.harness if session else None
168
+ try:
169
+ result = client.upload(upload_data, upload_filename, harness=harness)
170
+ except APIError as e:
171
+ click.secho(f"Upload failed: {e.message}", fg="red", err=True)
172
+ sys.exit(1)
173
+
174
+ slug = result["slug"]
175
+ view_url = result["url"]
176
+ delete_token = result["delete_token"]
177
+ manage_url = f"{view_url}manage/?token={delete_token}"
178
+
179
+ session_save(
180
+ slug,
181
+ {
182
+ "delete_token": delete_token,
183
+ "url": view_url,
184
+ "uploaded_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
185
+ "server": server_url,
186
+ "filename": upload_filename,
187
+ },
188
+ )
189
+
190
+ click.echo("Uploaded.")
191
+ click.secho(f"View: {view_url}", fg="green")
192
+ click.secho(f"Manage: {manage_url}", fg="green")
193
+
194
+
195
+ def _export_opencode_session(session: SessionInfo) -> tuple[bytes, str]:
196
+ binary = shutil.which("opencode")
197
+ if binary is None:
198
+ fallback = Path.home() / ".opencode" / "bin" / "opencode"
199
+ if fallback.is_file():
200
+ binary = str(fallback)
201
+ if binary is None:
202
+ click.secho(
203
+ "opencode binary not found. Install it or add it to PATH.",
204
+ fg="red",
205
+ err=True,
206
+ )
207
+ sys.exit(1)
208
+ if not session.session_id:
209
+ click.secho("Session has no ID for export.", fg="red", err=True)
210
+ sys.exit(1)
211
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".json")
212
+ try:
213
+ with open(tmp_fd, "w") as out:
214
+ proc = subprocess.run(
215
+ [binary, "export", session.session_id],
216
+ stdout=out,
217
+ stderr=subprocess.PIPE,
218
+ cwd=session.worktree,
219
+ timeout=30,
220
+ )
221
+ if proc.returncode != 0:
222
+ stderr = proc.stderr.decode(errors="replace").strip()
223
+ click.secho(f"opencode export failed: {stderr}", fg="red", err=True)
224
+ sys.exit(1)
225
+ return Path(tmp_path).read_bytes(), f"{session.session_id}.json"
226
+ except subprocess.TimeoutExpired:
227
+ click.secho("opencode export timed out after 30 seconds.", fg="red", err=True)
228
+ sys.exit(1)
229
+ except OSError as e:
230
+ click.secho(f"opencode export failed: {e}", fg="red", err=True)
231
+ sys.exit(1)
232
+ finally:
233
+ Path(tmp_path).unlink(missing_ok=True)
234
+
235
+
236
+ @cli.command("list")
237
+ def list_cmd():
238
+ """List locally-tracked uploads."""
239
+ entries = sessions_all()
240
+ if not entries:
241
+ click.echo("No uploads tracked locally.")
242
+ return
243
+
244
+ click.echo(f"{'URL':<45} {'FILENAME':<30} UPLOADED")
245
+ for e in entries:
246
+ click.echo(f"{e.get('url', ''):<45} {e.get('filename', ''):<30} {e.get('uploaded_at', '')}")
247
+
248
+
249
+ @cli.command()
250
+ @click.argument("slug")
251
+ @click.option("--server", default=None, help="Override the server URL.")
252
+ def delete(slug: str, server: str | None):
253
+ """Delete an uploaded session."""
254
+ entry = session_get(slug)
255
+ if entry is None:
256
+ click.secho(f"No local record for slug '{slug}'.", fg="red", err=True)
257
+ sys.exit(1)
258
+
259
+ server_url = server or entry.get("server")
260
+ if not server_url:
261
+ server_url = resolve_server_url(None)
262
+
263
+ client = SessionbinClient(server_url)
264
+ try:
265
+ client.delete(slug, entry["delete_token"])
266
+ except APIError as e:
267
+ click.secho(f"Delete failed: {e.message}", fg="red", err=True)
268
+ sys.exit(1)
269
+
270
+ session_remove(slug)
271
+ click.echo(f"Deleted {slug}.")
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import click
7
+ import platformdirs
8
+
9
+ DEFAULT_SERVER_URL = "https://sessionbin.dev"
10
+
11
+
12
+ def config_dir() -> Path:
13
+ return platformdirs.user_config_path("sessionbin")
14
+
15
+
16
+ def config_file() -> Path:
17
+ return config_dir() / "config.toml"
18
+
19
+
20
+ def _read_config_url() -> str | None:
21
+ path = config_file()
22
+ if not path.exists():
23
+ return None
24
+ for line in path.read_text().splitlines():
25
+ line = line.strip()
26
+ if line.startswith("server_url"):
27
+ _, _, value = line.partition("=")
28
+ value = value.strip().strip('"').strip("'")
29
+ if value:
30
+ return value
31
+ return None
32
+
33
+
34
+ def _prompt_and_save() -> str:
35
+ click.echo("No server configured. Enter your sessionbin instance URL.")
36
+ url = click.prompt("Server URL", default=DEFAULT_SERVER_URL)
37
+ url = url.rstrip("/")
38
+ path = config_file()
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ path.write_text(f'server_url = "{url}"\n')
41
+ click.echo(f"Saved to {path}")
42
+ return url
43
+
44
+
45
+ def resolve_server_url(server_flag: str | None = None) -> str:
46
+ if server_flag:
47
+ return server_flag.rstrip("/")
48
+
49
+ env = os.environ.get("SESSIONBIN_URL")
50
+ if env:
51
+ return env.rstrip("/")
52
+
53
+ from_config = _read_config_url()
54
+ if from_config:
55
+ return from_config.rstrip("/")
56
+
57
+ return _prompt_and_save()
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Callable, Literal
8
+
9
+ import platformdirs
10
+
11
+ CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
12
+ OPENCODE_DB_PATH = platformdirs.user_data_path("opencode") / "opencode.db"
13
+
14
+
15
+ @dataclass
16
+ class SessionInfo:
17
+ path: Path
18
+ mtime: float
19
+ project: str
20
+ title: str | None
21
+ summary: str | None
22
+ harness: Literal["claude-code", "opencode"]
23
+ session_id: str | None = None
24
+ worktree: Path | None = None
25
+
26
+
27
+ def _claude_extract_first_user_message(obj: dict) -> str | None:
28
+ if obj.get("type") != "user":
29
+ return None
30
+ msg = obj.get("message", "")
31
+ if isinstance(msg, str):
32
+ text = msg
33
+ elif isinstance(msg, dict):
34
+ content = msg.get("content", "")
35
+ if isinstance(content, str):
36
+ text = content
37
+ elif isinstance(content, list):
38
+ for block in content:
39
+ if isinstance(block, dict) and block.get("type") == "text":
40
+ text = block["text"]
41
+ break
42
+ else:
43
+ return None
44
+ else:
45
+ return None
46
+ else:
47
+ return None
48
+ text = text.strip()
49
+ if text.startswith("<"):
50
+ return None
51
+ return text if text else None
52
+
53
+
54
+ def _claude_read_metadata(path: Path) -> tuple[str | None, str | None, str | None]:
55
+ title = None
56
+ summary = None
57
+ cwd = None
58
+ try:
59
+ with open(path) as f:
60
+ for line in f:
61
+ obj = json.loads(line)
62
+ if obj.get("type") == "custom-title":
63
+ title = obj.get("customTitle")
64
+ if summary is None:
65
+ summary = _claude_extract_first_user_message(obj)
66
+ if cwd is None and "cwd" in obj:
67
+ cwd = obj["cwd"]
68
+ if title is not None and summary is not None and cwd is not None:
69
+ break
70
+ except (json.JSONDecodeError, OSError):
71
+ pass
72
+ return title, summary, cwd
73
+
74
+
75
+ def find_claude_sessions() -> list[SessionInfo]:
76
+ if not CLAUDE_PROJECTS_DIR.is_dir():
77
+ return []
78
+ results: list[SessionInfo] = []
79
+ for project_dir in CLAUDE_PROJECTS_DIR.iterdir():
80
+ if not project_dir.is_dir():
81
+ continue
82
+ for f in project_dir.iterdir():
83
+ if f.suffix == ".jsonl" and f.is_file():
84
+ title, summary, cwd = _claude_read_metadata(f)
85
+ project = Path(cwd).name if cwd else project_dir.name
86
+ results.append(
87
+ SessionInfo(
88
+ path=f,
89
+ mtime=f.stat().st_mtime,
90
+ project=project,
91
+ title=title,
92
+ summary=summary,
93
+ harness="claude-code",
94
+ session_id=f.stem,
95
+ )
96
+ )
97
+ results.sort(key=lambda x: x.mtime, reverse=True)
98
+ return results
99
+
100
+
101
+ def find_opencode_sessions() -> list[SessionInfo]:
102
+ if not OPENCODE_DB_PATH.is_file():
103
+ return []
104
+ try:
105
+ conn = sqlite3.connect(f"file:{OPENCODE_DB_PATH}?mode=ro", uri=True)
106
+ except sqlite3.OperationalError:
107
+ return []
108
+ try:
109
+ rows = conn.execute(
110
+ "SELECT s.id, s.title, s.directory, s.time_updated, p.worktree "
111
+ "FROM session s "
112
+ "JOIN project p ON s.project_id = p.id "
113
+ "WHERE s.time_archived IS NULL"
114
+ ).fetchall()
115
+ except sqlite3.OperationalError:
116
+ return []
117
+ finally:
118
+ conn.close()
119
+ results: list[SessionInfo] = []
120
+ for sid, title, directory, time_updated, worktree in rows:
121
+ project = Path(directory).name if directory else Path(worktree).name
122
+ results.append(
123
+ SessionInfo(
124
+ path=Path(directory or worktree),
125
+ mtime=time_updated / 1000,
126
+ project=project,
127
+ title=title,
128
+ summary=None,
129
+ session_id=sid,
130
+ worktree=Path(worktree),
131
+ harness="opencode",
132
+ )
133
+ )
134
+ return results
135
+
136
+
137
+ Detector = Callable[[], list[SessionInfo]]
138
+ DETECTORS: list[Detector] = [find_claude_sessions, find_opencode_sessions]
139
+
140
+
141
+ def find_all_sessions() -> list[SessionInfo]:
142
+ results: list[SessionInfo] = []
143
+ for detector in DETECTORS:
144
+ results.extend(detector())
145
+ results.sort(key=lambda x: x.mtime, reverse=True)
146
+ return results
147
+
148
+
149
+ def most_recent() -> SessionInfo | None:
150
+ results: list[SessionInfo] = []
151
+ for detector in DETECTORS:
152
+ results.extend(detector())
153
+ return max(results, key=lambda s: s.mtime, default=None)
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from sessionbin_cli.config import config_dir
9
+
10
+ SESSIONS_FILE = "sessions.json"
11
+
12
+
13
+ def _sessions_path() -> Path:
14
+ return config_dir() / SESSIONS_FILE
15
+
16
+
17
+ def load() -> dict[str, dict]:
18
+ path = _sessions_path()
19
+ if not path.exists():
20
+ return {}
21
+ return json.loads(path.read_text())
22
+
23
+
24
+ def save(slug: str, entry: dict) -> None:
25
+ data = load()
26
+ data[slug] = entry
27
+ _write(data)
28
+
29
+
30
+ def get(slug: str) -> dict | None:
31
+ return load().get(slug)
32
+
33
+
34
+ def remove(slug: str) -> None:
35
+ data = load()
36
+ data.pop(slug, None)
37
+ _write(data)
38
+
39
+
40
+ def all() -> list[dict]:
41
+ data = load()
42
+ entries = [{"slug": slug, **v} for slug, v in data.items()]
43
+ entries.sort(key=lambda e: e.get("uploaded_at", ""), reverse=True)
44
+ return entries
45
+
46
+
47
+ def _write(data: dict) -> None:
48
+ path = _sessions_path()
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
51
+ try:
52
+ with os.fdopen(fd, "w") as f:
53
+ json.dump(data, f, indent=2)
54
+ f.write("\n")
55
+ os.chmod(tmp, 0o600)
56
+ os.replace(tmp, path)
57
+ except BaseException:
58
+ os.unlink(tmp)
59
+ raise
sessionbin-0.0.1/PKG-INFO DELETED
@@ -1,14 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: sessionbin
3
- Version: 0.0.1
4
- Summary: sessionbin CLI
5
- Author: Martin Prpič
6
- Author-email: Martin Prpič <martin.prpic@gmail.com>
7
- License-Expression: MIT
8
- Requires-Dist: click>=8.3.0
9
- Requires-Python: >=3.10
10
- Project-URL: Homepage, https://github.com/sessionbin/sessionbin-cli
11
- Project-URL: Repository, https://github.com/sessionbin/sessionbin-cli
12
- Description-Content-Type: text/markdown
13
-
14
- # sessionbin-cli
@@ -1 +0,0 @@
1
- # sessionbin-cli
@@ -1,43 +0,0 @@
1
- [build-system]
2
- requires = ["uv_build>=0.9.3,<0.10.0"]
3
- build-backend = "uv_build"
4
-
5
- [project]
6
- name = "sessionbin"
7
- version = "0.0.1"
8
- description = "sessionbin CLI"
9
- readme = "README.md"
10
- license = "MIT"
11
- authors = [
12
- { name = "Martin Prpič", email = "martin.prpic@gmail.com" }
13
- ]
14
- requires-python = ">=3.10"
15
- dependencies = [
16
- "click>=8.3.0",
17
- ]
18
-
19
- [project.urls]
20
- Homepage = "https://github.com/sessionbin/sessionbin-cli"
21
- Repository = "https://github.com/sessionbin/sessionbin-cli"
22
-
23
- [project.scripts]
24
- sessionbin = "sessionbin.cli:cli"
25
-
26
- [dependency-groups]
27
- dev = [
28
- "pytest>=8.4.2",
29
- ]
30
-
31
- [tool.ruff]
32
- line-length = 100
33
-
34
- [tool.ruff.lint]
35
- # https://docs.astral.sh/ruff/rules/
36
- select = ["F", "E", "W", "I", "N"]
37
-
38
- [tool.mypy]
39
- python_version = "3.13"
40
- warn_unused_configs = true
41
- warn_unreachable = true
42
- warn_no_return = true
43
- warn_unused_ignores = true
@@ -1 +0,0 @@
1
- __version__ = "0.0.1"
@@ -1,11 +0,0 @@
1
- import click
2
-
3
- CONTEXT_SETTINGS = {
4
- "help_option_names": ["-h", "--help"],
5
- }
6
-
7
-
8
- @click.command(context_settings=CONTEXT_SETTINGS)
9
- def cli():
10
- """sessionbin CLI."""
11
- click.echo("sessionbin")