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.
- sessionbin-0.2.0/PKG-INFO +101 -0
- sessionbin-0.2.0/README.md +85 -0
- sessionbin-0.2.0/pyproject.toml +56 -0
- sessionbin-0.2.0/pyproject.toml.orig +56 -0
- sessionbin-0.2.0/src/sessionbin_cli/__init__.py +1 -0
- sessionbin-0.2.0/src/sessionbin_cli/api.py +35 -0
- sessionbin-0.2.0/src/sessionbin_cli/cli.py +271 -0
- sessionbin-0.2.0/src/sessionbin_cli/config.py +57 -0
- sessionbin-0.2.0/src/sessionbin_cli/detect.py +153 -0
- sessionbin-0.2.0/src/sessionbin_cli/sessions.py +59 -0
- sessionbin-0.0.1/PKG-INFO +0 -14
- sessionbin-0.0.1/README.md +0 -1
- sessionbin-0.0.1/pyproject.toml +0 -43
- sessionbin-0.0.1/src/sessionbin/__init__.py +0 -1
- sessionbin-0.0.1/src/sessionbin/cli.py +0 -11
|
@@ -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
|
sessionbin-0.0.1/README.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# sessionbin-cli
|
sessionbin-0.0.1/pyproject.toml
DELETED
|
@@ -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"
|