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.
- {sessionbin-0.1.0 → sessionbin-0.2.0}/PKG-INFO +13 -5
- {sessionbin-0.1.0 → sessionbin-0.2.0}/README.md +10 -2
- sessionbin-0.2.0/pyproject.toml +56 -0
- sessionbin-0.1.0/pyproject.toml → sessionbin-0.2.0/pyproject.toml.orig +15 -5
- sessionbin-0.2.0/src/sessionbin_cli/__init__.py +1 -0
- {sessionbin-0.1.0/src/sessionbin → sessionbin-0.2.0/src/sessionbin_cli}/api.py +3 -5
- sessionbin-0.2.0/src/sessionbin_cli/cli.py +271 -0
- {sessionbin-0.1.0/src/sessionbin → sessionbin-0.2.0/src/sessionbin_cli}/detect.py +51 -8
- {sessionbin-0.1.0/src/sessionbin → sessionbin-0.2.0/src/sessionbin_cli}/sessions.py +1 -1
- sessionbin-0.1.0/src/sessionbin/__init__.py +0 -1
- sessionbin-0.1.0/src/sessionbin/cli.py +0 -194
- {sessionbin-0.1.0/src/sessionbin → sessionbin-0.2.0/src/sessionbin_cli}/config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sessionbin
|
|
3
|
-
Version: 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
|
|
14
|
-
Project-URL: Repository, https://github.com/sessionbin/sessionbin
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
24
|
-
Repository = "https://github.com/sessionbin/sessionbin
|
|
23
|
+
Homepage = "https://github.com/sessionbin/sessionbin"
|
|
24
|
+
Repository = "https://github.com/sessionbin/sessionbin"
|
|
25
25
|
|
|
26
26
|
[project.scripts]
|
|
27
|
-
sessionbin = "
|
|
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,
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
110
|
-
|
|
150
|
+
results: list[SessionInfo] = []
|
|
151
|
+
for detector in DETECTORS:
|
|
152
|
+
results.extend(detector())
|
|
153
|
+
return max(results, key=lambda s: s.mtime, default=None)
|
|
@@ -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}.")
|
|
File without changes
|