claude-opencode-sessions 0.1.0__py3-none-any.whl
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.
- claude_opencode_sessions/__init__.py +10 -0
- claude_opencode_sessions/__main__.py +3 -0
- claude_opencode_sessions/cli.py +196 -0
- claude_opencode_sessions/cli_backend.py +140 -0
- claude_opencode_sessions/errors.py +39 -0
- claude_opencode_sessions/import_command.py +216 -0
- claude_opencode_sessions/importer.py +414 -0
- claude_opencode_sessions/locate.py +90 -0
- claude_opencode_sessions/models.py +96 -0
- claude_opencode_sessions/parse.py +224 -0
- claude_opencode_sessions/py.typed +0 -0
- claude_opencode_sessions/render.py +169 -0
- claude_opencode_sessions/scope.py +160 -0
- claude_opencode_sessions/service.py +149 -0
- claude_opencode_sessions/sqlite_backend.py +249 -0
- claude_opencode_sessions/tests/__init__.py +0 -0
- claude_opencode_sessions/tests/conftest.py +314 -0
- claude_opencode_sessions/tests/fixtures/schema_1x.sql +76 -0
- claude_opencode_sessions/tests/fixtures/schema_2x.sql +37 -0
- claude_opencode_sessions/tests/test_cli.py +140 -0
- claude_opencode_sessions/tests/test_cli_backend.py +116 -0
- claude_opencode_sessions/tests/test_importer.py +504 -0
- claude_opencode_sessions/tests/test_launcher.py +47 -0
- claude_opencode_sessions/tests/test_locate.py +62 -0
- claude_opencode_sessions/tests/test_parse.py +120 -0
- claude_opencode_sessions/tests/test_render.py +107 -0
- claude_opencode_sessions/tests/test_scope.py +121 -0
- claude_opencode_sessions/tests/test_sqlite_backend.py +157 -0
- claude_opencode_sessions-0.1.0.dist-info/METADATA +287 -0
- claude_opencode_sessions-0.1.0.dist-info/RECORD +33 -0
- claude_opencode_sessions-0.1.0.dist-info/WHEEL +4 -0
- claude_opencode_sessions-0.1.0.dist-info/entry_points.txt +2 -0
- claude_opencode_sessions-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from claude_opencode_sessions.cli import main
|
|
7
|
+
from claude_opencode_sessions.tests.conftest import (
|
|
8
|
+
HOUR,
|
|
9
|
+
NOW,
|
|
10
|
+
DbBuilder,
|
|
11
|
+
assistant,
|
|
12
|
+
text,
|
|
13
|
+
tool,
|
|
14
|
+
user,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def populated(db_1x: DbBuilder, git_repo, monkeypatch: pytest.MonkeyPatch):
|
|
20
|
+
repo, feature, other = git_repo["repo"], git_repo["feature"], git_repo["other"]
|
|
21
|
+
db_1x.session_1x(
|
|
22
|
+
"ses_newest",
|
|
23
|
+
str(repo / "sub"),
|
|
24
|
+
title="Newest in repo",
|
|
25
|
+
updated=NOW,
|
|
26
|
+
messages=[
|
|
27
|
+
user("fix the parser"),
|
|
28
|
+
assistant(text("on it"), tool("edit", {"filePath": "parser.py"})),
|
|
29
|
+
assistant(text("done, the needle was misplaced")),
|
|
30
|
+
],
|
|
31
|
+
)
|
|
32
|
+
db_1x.session_1x(
|
|
33
|
+
"ses_older",
|
|
34
|
+
str(repo),
|
|
35
|
+
title="Older in repo",
|
|
36
|
+
updated=NOW - HOUR,
|
|
37
|
+
messages=[user("older question")],
|
|
38
|
+
)
|
|
39
|
+
db_1x.session_1x("ses_archived", str(repo), updated=NOW - 2 * HOUR, archived=NOW)
|
|
40
|
+
db_1x.session_1x(
|
|
41
|
+
"ses_child", str(repo), updated=NOW - 3 * HOUR, parent_id="ses_newest"
|
|
42
|
+
)
|
|
43
|
+
db_1x.session_1x(
|
|
44
|
+
"ses_feature", str(feature), title="Feature work", updated=NOW - 4 * HOUR
|
|
45
|
+
)
|
|
46
|
+
db_1x.session_1x(
|
|
47
|
+
"ses_other",
|
|
48
|
+
str(other),
|
|
49
|
+
title="Elsewhere",
|
|
50
|
+
updated=NOW - 5 * HOUR,
|
|
51
|
+
project_id="proj2",
|
|
52
|
+
messages=[user("unrelated")],
|
|
53
|
+
)
|
|
54
|
+
db_1x.close()
|
|
55
|
+
monkeypatch.setenv("OPENCODE_SESSIONS_DB", str(db_1x.path))
|
|
56
|
+
monkeypatch.chdir(repo / "sub")
|
|
57
|
+
return git_repo
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run(capsys, *argv: str) -> tuple[int, str, str]:
|
|
61
|
+
code = main(list(argv))
|
|
62
|
+
captured = capsys.readouterr()
|
|
63
|
+
return code, captured.out, captured.err
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_list_default_scope_is_current_worktree(populated, capsys):
|
|
67
|
+
code, out, _ = run(capsys, "list")
|
|
68
|
+
assert code == 0
|
|
69
|
+
assert "Newest in repo" in out and "Older in repo" in out
|
|
70
|
+
assert "Feature work" not in out and "Elsewhere" not in out
|
|
71
|
+
assert "1 archived" in out and "1 sub-agent" in out
|
|
72
|
+
assert out.index("Newest in repo") < out.index("Older in repo")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_list_worktrees_and_all(populated, capsys):
|
|
76
|
+
_, out, _ = run(capsys, "list", "--worktrees")
|
|
77
|
+
assert "Feature work" in out and "Elsewhere" not in out
|
|
78
|
+
_, out, _ = run(
|
|
79
|
+
capsys, "list", "--all", "--include-archived", "--include-subagents"
|
|
80
|
+
)
|
|
81
|
+
for title in (
|
|
82
|
+
"Elsewhere",
|
|
83
|
+
"Feature work",
|
|
84
|
+
"Session ses_archived",
|
|
85
|
+
"Session ses_child",
|
|
86
|
+
):
|
|
87
|
+
assert title in out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_list_limit_and_json(populated, capsys):
|
|
91
|
+
code, out, _ = run(capsys, "list", "--all", "-n", "1", "--format", "json")
|
|
92
|
+
payload = json.loads(out)
|
|
93
|
+
assert code == 0
|
|
94
|
+
assert payload["total"] == 4
|
|
95
|
+
assert len(payload["sessions"]) == 1
|
|
96
|
+
assert payload["sessions"][0]["id"] == "ses_newest"
|
|
97
|
+
assert payload["sessions"][0]["index"] == 1
|
|
98
|
+
assert payload["backend"] == "sqlite"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_list_path_option(populated, capsys):
|
|
102
|
+
_, out, _ = run(capsys, "list", "--path", str(populated["feature"]))
|
|
103
|
+
assert "Feature work" in out and "Newest in repo" not in out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_doctor(populated, capsys):
|
|
107
|
+
code, out, _ = run(capsys, "doctor")
|
|
108
|
+
assert code == 0
|
|
109
|
+
assert "layout: session · 1.x message/part" in out
|
|
110
|
+
assert "sessions readable: 6" in out
|
|
111
|
+
assert "scope worktree: git worktree" in out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_doctor_without_db(capsys, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
115
|
+
monkeypatch.setenv("OPENCODE_SESSIONS_DB", str(tmp_path / "missing.db"))
|
|
116
|
+
code, out, _ = run(capsys, "doctor")
|
|
117
|
+
assert code == 3 and "NOT USABLE" in out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_sqlite_backend_missing_db_errors(capsys, tmp_path: Path):
|
|
121
|
+
code, _, err = run(
|
|
122
|
+
capsys, "list", "--backend", "sqlite", "--db", str(tmp_path / "x.db")
|
|
123
|
+
)
|
|
124
|
+
assert code == 3 and "not found" in err
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_auto_without_db_or_cli(
|
|
128
|
+
capsys, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
129
|
+
):
|
|
130
|
+
monkeypatch.setenv("PATH", str(tmp_path)) # no opencode, no git
|
|
131
|
+
code, _, err = run(capsys, "list", "--db", str(tmp_path / "x.db"))
|
|
132
|
+
assert code == 4
|
|
133
|
+
assert "sqlite unavailable" in err and "cli unavailable" in err
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_version(capsys):
|
|
137
|
+
with pytest.raises(SystemExit) as exc:
|
|
138
|
+
main(["--version"])
|
|
139
|
+
assert exc.value.code == 0
|
|
140
|
+
assert "claude-opencode-sessions" in capsys.readouterr().out
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from claude_opencode_sessions.cli import main
|
|
7
|
+
from claude_opencode_sessions.cli_backend import (
|
|
8
|
+
CliBackend,
|
|
9
|
+
_extract_json,
|
|
10
|
+
parse_version,
|
|
11
|
+
)
|
|
12
|
+
from claude_opencode_sessions.errors import BackendError
|
|
13
|
+
from claude_opencode_sessions.models import Message, Session
|
|
14
|
+
from claude_opencode_sessions.service import Context, open_context
|
|
15
|
+
from claude_opencode_sessions.tests.conftest import DbBuilder, text_of
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_parse_version():
|
|
19
|
+
assert parse_version("opencode 1.18.32") == (1, 18, 32)
|
|
20
|
+
assert parse_version("2.0") == (2, 0)
|
|
21
|
+
assert parse_version("dev") is None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_extract_json_skips_log_lines():
|
|
25
|
+
assert _extract_json('INFO starting\n{"a": [1]}\n') == {"a": [1]}
|
|
26
|
+
assert _extract_json("noise [1, 2]") == [1, 2]
|
|
27
|
+
with pytest.raises(BackendError):
|
|
28
|
+
_extract_json("no json {here")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_cli_backend_list_and_export(fake_opencode: Path):
|
|
32
|
+
backend = CliBackend()
|
|
33
|
+
assert backend.version == (1, 18, 32)
|
|
34
|
+
[session] = backend.list_sessions()
|
|
35
|
+
assert session.id == "ses_cli_1" and session.origin == "cli"
|
|
36
|
+
info, messages = backend.export("ses_cli_1")
|
|
37
|
+
assert info is not None and info.title == "From the CLI"
|
|
38
|
+
assert [text_of(m) for m in messages] == ["hello from export", "hi back"]
|
|
39
|
+
assert messages[1].model == "openai/gpt-x"
|
|
40
|
+
with pytest.raises(BackendError, match="exited 1"):
|
|
41
|
+
backend.export("ses_missing")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_cli_backend_rejects_old_version(fake_opencode: Path):
|
|
45
|
+
data = json.loads(fake_opencode.read_text())
|
|
46
|
+
data["version"] = "0.9.1"
|
|
47
|
+
fake_opencode.write_text(json.dumps(data))
|
|
48
|
+
with pytest.raises(BackendError, match="too old"):
|
|
49
|
+
CliBackend()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_cli_backend_missing_executable(
|
|
53
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
54
|
+
):
|
|
55
|
+
monkeypatch.setenv("PATH", str(tmp_path))
|
|
56
|
+
with pytest.raises(BackendError, match="not found on PATH"):
|
|
57
|
+
CliBackend()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_auto_falls_back_to_cli(
|
|
61
|
+
fake_opencode: Path, tmp_path: Path, capsys, monkeypatch
|
|
62
|
+
):
|
|
63
|
+
project = tmp_path / "proj"
|
|
64
|
+
project.mkdir()
|
|
65
|
+
monkeypatch.setenv("OPENCODE_SESSIONS_DB", str(tmp_path / "missing.db"))
|
|
66
|
+
code = main(["list", "--path", str(project)])
|
|
67
|
+
out = capsys.readouterr().out
|
|
68
|
+
assert code == 0
|
|
69
|
+
assert "From the CLI" in out
|
|
70
|
+
assert "backend: cli (opencode CLI 1.18.32)" in out
|
|
71
|
+
assert "sqlite unavailable" in out
|
|
72
|
+
target = tmp_path / "claude-project"
|
|
73
|
+
code = main(["import", "--path", str(project), "--claude-project-dir", str(target)])
|
|
74
|
+
out = capsys.readouterr().out
|
|
75
|
+
assert code == 0 and "1 imported" in out
|
|
76
|
+
[imported] = target.glob("*.jsonl")
|
|
77
|
+
assert "hello from export" in imported.read_text()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_unparseable_sqlite_session_falls_back_to_export(
|
|
81
|
+
fake_opencode: Path, db_1x: DbBuilder
|
|
82
|
+
):
|
|
83
|
+
db_1x.session_1x(
|
|
84
|
+
"ses_cli_1",
|
|
85
|
+
"/w",
|
|
86
|
+
messages=[({"role": "user"}, ["{bad"]), ({"role": "user"}, ["{bad"])],
|
|
87
|
+
)
|
|
88
|
+
db_1x.close()
|
|
89
|
+
ctx = open_context("auto", str(db_1x.path))
|
|
90
|
+
session = Session(id="ses_cli_1", title="t", directory="/w")
|
|
91
|
+
messages = ctx.messages(session)
|
|
92
|
+
assert text_of(messages[0]) == "hello from export"
|
|
93
|
+
assert any("fell back" in note for note in ctx.notes)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_unparseable_sqlite_session_without_cli_keeps_partial(
|
|
97
|
+
db_1x: DbBuilder, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
98
|
+
):
|
|
99
|
+
db_1x.session_1x("ses_a", "/w", messages=[({"role": "user"}, ["{bad"])])
|
|
100
|
+
db_1x.close()
|
|
101
|
+
monkeypatch.setenv("PATH", str(tmp_path))
|
|
102
|
+
ctx = open_context("auto", str(db_1x.path))
|
|
103
|
+
messages = ctx.messages(Session(id="ses_a", title="t", directory="/w"))
|
|
104
|
+
assert len(messages) == 1 and isinstance(messages[0], Message)
|
|
105
|
+
assert any("could not be parsed" in note for note in ctx.notes)
|
|
106
|
+
assert any("cli unavailable" in note for note in ctx.notes)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_open_context_rejects_unknown_backend():
|
|
110
|
+
with pytest.raises(ValueError):
|
|
111
|
+
open_context("nope")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_context_describe_cli(fake_opencode: Path):
|
|
115
|
+
ctx = Context(mode="cli", cli=CliBackend())
|
|
116
|
+
assert ctx.describe().startswith("cli (opencode CLI")
|