sessionbin 0.1.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sessionbin
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: CLI for uploading and managing sessionbin transcripts
5
5
  Author: Martin Prpič
6
6
  Author-email: Martin Prpič <martin.prpic@gmail.com>
@@ -10,14 +10,22 @@ Requires-Dist: httpx>=0.27
10
10
  Requires-Dist: platformdirs>=4.3
11
11
  Requires-Dist: questionary>=2.1
12
12
  Requires-Python: >=3.10
13
- Project-URL: Homepage, https://github.com/sessionbin/sessionbin-cli
14
- Project-URL: Repository, https://github.com/sessionbin/sessionbin-cli
13
+ Project-URL: Homepage, https://github.com/sessionbin/sessionbin
14
+ Project-URL: Repository, https://github.com/sessionbin/sessionbin
15
15
  Description-Content-Type: text/markdown
16
16
 
17
- # sessionbin-cli
17
+ # sessionbin CLI
18
18
 
19
19
  CLI for uploading and managing [sessionbin](https://sessionbin.dev) transcripts.
20
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
+
21
29
  ## Installation
22
30
 
23
31
  ```bash
@@ -36,7 +44,7 @@ Upload a specific file:
36
44
  sessionbin upload ~/.claude/projects/-home-user-repos-myproject/abc123.jsonl
37
45
  ```
38
46
 
39
- Or pick from recent Claude Code sessions interactively:
47
+ Or pick from recent sessions interactively (Claude Code and OpenCode):
40
48
 
41
49
  ```bash
42
50
  sessionbin upload
@@ -1,7 +1,15 @@
1
- # sessionbin-cli
1
+ # sessionbin CLI
2
2
 
3
3
  CLI for uploading and managing [sessionbin](https://sessionbin.dev) transcripts.
4
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
+
5
13
  ## Installation
6
14
 
7
15
  ```bash
@@ -20,7 +28,7 @@ Upload a specific file:
20
28
  sessionbin upload ~/.claude/projects/-home-user-repos-myproject/abc123.jsonl
21
29
  ```
22
30
 
23
- Or pick from recent Claude Code sessions interactively:
31
+ Or pick from recent sessions interactively (Claude Code and OpenCode):
24
32
 
25
33
  ```bash
26
34
  sessionbin upload
@@ -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"]
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["uv_build>=0.9.3,<0.10.0"]
2
+ requires = ["uv_build>=0.12.0,<0.13.0"]
3
3
  build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "sessionbin"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "CLI for uploading and managing sessionbin transcripts"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -20,11 +20,16 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  [project.urls]
23
- Homepage = "https://github.com/sessionbin/sessionbin-cli"
24
- Repository = "https://github.com/sessionbin/sessionbin-cli"
23
+ Homepage = "https://github.com/sessionbin/sessionbin"
24
+ Repository = "https://github.com/sessionbin/sessionbin"
25
25
 
26
26
  [project.scripts]
27
- sessionbin = "sessionbin.cli:cli"
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"
28
33
 
29
34
  [dependency-groups]
30
35
  dev = [
@@ -38,6 +43,11 @@ line-length = 100
38
43
  # https://docs.astral.sh/ruff/rules/
39
44
  select = ["F", "E", "W", "I", "N"]
40
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
+
41
51
  [tool.mypy]
42
52
  python_version = "3.13"
43
53
  warn_unused_configs = true
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -1,7 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import pathlib
4
-
5
3
  import httpx
6
4
 
7
5
 
@@ -16,9 +14,9 @@ class SessionbinClient:
16
14
  def __init__(self, base_url: str, timeout: float = 60.0) -> None:
17
15
  self.client = httpx.Client(base_url=base_url, timeout=timeout)
18
16
 
19
- def upload(self, path: pathlib.Path) -> dict:
20
- with open(path, "rb") as f:
21
- resp = self.client.post("/api/upload", files={"file": (path.name, f)})
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)
22
20
  if resp.status_code != 200:
23
21
  raise self._error(resp)
24
22
  return resp.json()
@@ -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}.")
@@ -1,11 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import sqlite3
4
5
  from dataclasses import dataclass
5
6
  from pathlib import Path
6
- from typing import Callable
7
+ from typing import Callable, Literal
8
+
9
+ import platformdirs
7
10
 
8
11
  CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
12
+ OPENCODE_DB_PATH = platformdirs.user_data_path("opencode") / "opencode.db"
9
13
 
10
14
 
11
15
  @dataclass
@@ -15,6 +19,9 @@ class SessionInfo:
15
19
  project: str
16
20
  title: str | None
17
21
  summary: str | None
22
+ harness: Literal["claude-code", "opencode"]
23
+ session_id: str | None = None
24
+ worktree: Path | None = None
18
25
 
19
26
 
20
27
  def _claude_extract_first_user_message(obj: dict) -> str | None:
@@ -83,18 +90,52 @@ def find_claude_sessions() -> list[SessionInfo]:
83
90
  project=project,
84
91
  title=title,
85
92
  summary=summary,
93
+ harness="claude-code",
94
+ session_id=f.stem,
86
95
  )
87
96
  )
88
97
  results.sort(key=lambda x: x.mtime, reverse=True)
89
98
  return results
90
99
 
91
100
 
92
- # Extension point: add detector functions for other harnesses here.
93
- # E.g. find_codex_sessions() for ~/.codex/sessions/,
94
- # find_opencode_sessions() for ~/.local/share/opencode/storage/,
95
- # find_gemini_sessions() for ~/.gemini/tmp/.
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
+
96
137
  Detector = Callable[[], list[SessionInfo]]
97
- DETECTORS: list[Detector] = [find_claude_sessions]
138
+ DETECTORS: list[Detector] = [find_claude_sessions, find_opencode_sessions]
98
139
 
99
140
 
100
141
  def find_all_sessions() -> list[SessionInfo]:
@@ -106,5 +147,7 @@ def find_all_sessions() -> list[SessionInfo]:
106
147
 
107
148
 
108
149
  def most_recent() -> SessionInfo | None:
109
- sessions = find_all_sessions()
110
- return sessions[0] if sessions else 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)
@@ -5,7 +5,7 @@ import os
5
5
  import tempfile
6
6
  from pathlib import Path
7
7
 
8
- from sessionbin.config import config_dir
8
+ from sessionbin_cli.config import config_dir
9
9
 
10
10
  SESSIONS_FILE = "sessions.json"
11
11
 
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
@@ -1,194 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import sys
4
- import time
5
- from datetime import datetime, timezone
6
- from pathlib import Path
7
-
8
- import click
9
- import questionary
10
-
11
- from sessionbin.api import APIError, SessionbinClient
12
- from sessionbin.config import resolve_server_url
13
- from sessionbin.detect import SessionInfo, find_all_sessions, most_recent
14
- from sessionbin.sessions import all as sessions_all
15
- from sessionbin.sessions import get as session_get
16
- from sessionbin.sessions import remove as session_remove
17
- from sessionbin.sessions import save as session_save
18
-
19
- MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
20
-
21
- CONTEXT_SETTINGS = {
22
- "help_option_names": ["-h", "--help"],
23
- }
24
-
25
-
26
- def _human_size(nbytes: int) -> str:
27
- for unit in ("B", "KB", "MB", "GB"):
28
- if nbytes < 1024:
29
- return f"{nbytes:.1f} {unit}" if unit != "B" else f"{nbytes} {unit}"
30
- nbytes /= 1024 # type: ignore[assignment]
31
- return f"{nbytes:.1f} TB"
32
-
33
-
34
- def _time_ago(mtime: float) -> str:
35
- delta = time.time() - mtime
36
- if delta < 60:
37
- return "just now"
38
- if delta < 3600:
39
- mins = int(delta / 60)
40
- return f"{mins}m ago"
41
- if delta < 86400:
42
- hours = int(delta / 3600)
43
- return f"{hours}h ago"
44
- days = int(delta / 86400)
45
- return f"{days}d ago"
46
-
47
-
48
- def _short_summary(info: SessionInfo, max_len: int) -> str:
49
- text = info.title or info.summary or info.path.name
50
- text = " ".join(text.splitlines())
51
- if len(text) > max_len:
52
- text = text[:max_len] + "..."
53
- return text
54
-
55
-
56
- def _pick_session() -> Path | None:
57
- sessions = find_all_sessions()[:10]
58
- if not sessions:
59
- return None
60
-
61
- time_w = max(len(_time_ago(s.mtime)) for s in sessions)
62
- size_w = max(len(_human_size(s.path.stat().st_size)) for s in sessions)
63
- proj_w = max(len(s.project) for s in sessions)
64
- summary_max = 60
65
-
66
- def row(s: SessionInfo) -> str:
67
- ago = _time_ago(s.mtime)
68
- size = _human_size(s.path.stat().st_size)
69
- summary = _short_summary(s, summary_max)
70
- return f"{ago:<{time_w}} {size:>{size_w}} {s.project:<{proj_w}} {summary}"
71
-
72
- header = (
73
- f"{'TIME':<{time_w}} {'SIZE':>{size_w}} {'PROJECT':<{proj_w}} SESSION NAME / SUMMARY"
74
- )
75
- choices: list[questionary.Choice | questionary.Separator] = [
76
- questionary.Separator(header),
77
- *[questionary.Choice(title=row(s), value=s.path) for s in sessions],
78
- ]
79
- result = questionary.select(
80
- "Select a session to upload:",
81
- choices=choices,
82
- ).unsafe_ask()
83
- return result
84
-
85
-
86
- @click.group(context_settings=CONTEXT_SETTINGS)
87
- def cli():
88
- """CLI for uploading and managing sessionbin transcripts."""
89
-
90
-
91
- @cli.command()
92
- @click.argument("path", required=False, type=click.Path(exists=True))
93
- @click.option("--server", default=None, help="Override the server URL.")
94
- @click.option("-l", "--latest", is_flag=True, help="Upload the most recent session.")
95
- @click.option("-y", "--yes", is_flag=True, help="Skip the confirmation prompt.")
96
- def upload(path: str | None, server: str | None, latest: bool, yes: bool):
97
- """Upload a session file."""
98
- file_path: Path
99
- if path is not None:
100
- file_path = Path(path)
101
- elif latest:
102
- detected = most_recent()
103
- if detected is None:
104
- click.secho("No sessions found.", fg="red", err=True)
105
- sys.exit(1)
106
- file_path = detected.path
107
- stat = file_path.stat()
108
- size = _human_size(stat.st_size)
109
- ago = _time_ago(detected.mtime)
110
- click.echo(f"Found: {file_path} ({size}, modified {ago})")
111
- if not yes:
112
- if not click.confirm("Upload?", default=False):
113
- raise SystemExit(0)
114
- else:
115
- picked = _pick_session()
116
- if picked is None:
117
- click.secho("No sessions found.", fg="red", err=True)
118
- sys.exit(1)
119
- file_path = picked
120
-
121
- file_size = file_path.stat().st_size
122
- if file_size > MAX_UPLOAD_BYTES:
123
- click.secho(
124
- f"File is {_human_size(file_size)}, which exceeds the 10 MB upload limit.",
125
- fg="red",
126
- err=True,
127
- )
128
- sys.exit(1)
129
-
130
- server_url = resolve_server_url(server)
131
- client = SessionbinClient(server_url)
132
- try:
133
- result = client.upload(file_path)
134
- except APIError as e:
135
- click.secho(f"Upload failed: {e.message}", fg="red", err=True)
136
- sys.exit(1)
137
-
138
- slug = result["slug"]
139
- view_url = result["url"]
140
- delete_token = result["delete_token"]
141
- manage_url = f"{view_url}manage/?token={delete_token}"
142
-
143
- session_save(
144
- slug,
145
- {
146
- "delete_token": delete_token,
147
- "url": view_url,
148
- "uploaded_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
149
- "server": server_url,
150
- "filename": file_path.name,
151
- },
152
- )
153
-
154
- click.echo("Uploaded.")
155
- click.secho(f"View: {view_url}", fg="green")
156
- click.secho(f"Manage: {manage_url}", fg="green")
157
-
158
-
159
- @cli.command("list")
160
- def list_cmd():
161
- """List locally-tracked uploads."""
162
- entries = sessions_all()
163
- if not entries:
164
- click.echo("No uploads tracked locally.")
165
- return
166
-
167
- click.echo(f"{'URL':<45} {'FILENAME':<30} UPLOADED")
168
- for e in entries:
169
- click.echo(f"{e.get('url', ''):<45} {e.get('filename', ''):<30} {e.get('uploaded_at', '')}")
170
-
171
-
172
- @cli.command()
173
- @click.argument("slug")
174
- @click.option("--server", default=None, help="Override the server URL.")
175
- def delete(slug: str, server: str | None):
176
- """Delete an uploaded session."""
177
- entry = session_get(slug)
178
- if entry is None:
179
- click.secho(f"No local record for slug '{slug}'.", fg="red", err=True)
180
- sys.exit(1)
181
-
182
- server_url = server or entry.get("server")
183
- if not server_url:
184
- server_url = resolve_server_url(None)
185
-
186
- client = SessionbinClient(server_url)
187
- try:
188
- client.delete(slug, entry["delete_token"])
189
- except APIError as e:
190
- click.secho(f"Delete failed: {e.message}", fg="red", err=True)
191
- sys.exit(1)
192
-
193
- session_remove(slug)
194
- click.echo(f"Deleted {slug}.")