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,504 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from claude_opencode_sessions.cli import main
|
|
9
|
+
from claude_opencode_sessions.import_command import hook_main
|
|
10
|
+
from claude_opencode_sessions.importer import (
|
|
11
|
+
MARKER_KEY,
|
|
12
|
+
ImportFailedError,
|
|
13
|
+
build_conversation,
|
|
14
|
+
claude_project_dir,
|
|
15
|
+
content_hash,
|
|
16
|
+
existing_imports,
|
|
17
|
+
import_session,
|
|
18
|
+
project_dir_name,
|
|
19
|
+
)
|
|
20
|
+
from claude_opencode_sessions.models import Message, Part, Session
|
|
21
|
+
from claude_opencode_sessions.tests.conftest import (
|
|
22
|
+
HOUR,
|
|
23
|
+
NOW,
|
|
24
|
+
DbBuilder,
|
|
25
|
+
assistant,
|
|
26
|
+
text,
|
|
27
|
+
tool,
|
|
28
|
+
user,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_jsonl(path: Path) -> list[dict]:
|
|
33
|
+
return [json.loads(line) for line in path.read_text().splitlines()]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def msg(role, *parts, **kw):
|
|
37
|
+
return Message(
|
|
38
|
+
id=kw.pop("id", role), role=role, parts=list(parts), time_created=NOW, **kw
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def t(value, **kw):
|
|
43
|
+
return Part(kind="text", text=value, **kw)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# -- conversation ---------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_build_conversation_merges_and_filters():
|
|
50
|
+
turns = build_conversation(
|
|
51
|
+
[
|
|
52
|
+
msg(
|
|
53
|
+
"user",
|
|
54
|
+
t("fix it"),
|
|
55
|
+
t("<injected>", synthetic=True),
|
|
56
|
+
Part("file", text="a.png"),
|
|
57
|
+
),
|
|
58
|
+
msg(
|
|
59
|
+
"assistant",
|
|
60
|
+
Part("reasoning", text="hmm"),
|
|
61
|
+
t("looking"),
|
|
62
|
+
Part(
|
|
63
|
+
"tool", tool="bash", tool_input={"command": "ls"}, tool_output="x"
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
msg("assistant", t("done"), error="MessageAbortedError"),
|
|
67
|
+
msg("idle"),
|
|
68
|
+
msg("user", t("")), # empty -> dropped
|
|
69
|
+
msg("assistant", Part("compaction")),
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
assert [x.role for x in turns] == ["user", "assistant"]
|
|
73
|
+
assert turns[0].text == "fix it\n\n[attached: a.png]"
|
|
74
|
+
assert "hmm" not in turns[1].text
|
|
75
|
+
assert "→ bash: `ls`" in turns[1].text
|
|
76
|
+
assert "\n x" not in turns[1].text
|
|
77
|
+
assert "[error: MessageAbortedError]" in turns[1].text
|
|
78
|
+
assert turns[1].text.endswith("[context compacted]")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_build_conversation_with_tool_output():
|
|
82
|
+
turns = build_conversation(
|
|
83
|
+
[
|
|
84
|
+
msg(
|
|
85
|
+
"assistant",
|
|
86
|
+
Part(
|
|
87
|
+
"tool",
|
|
88
|
+
tool="bash",
|
|
89
|
+
tool_input={"command": "ls"},
|
|
90
|
+
tool_output="line1\nline2",
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
],
|
|
94
|
+
with_tool_output=True,
|
|
95
|
+
)
|
|
96
|
+
assert "→ bash: `ls`\n line1\n line2" in turns[0].text
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_content_hash_depends_only_on_content():
|
|
100
|
+
a = build_conversation([msg("user", t("q")), msg("assistant", t("a"))])
|
|
101
|
+
b = build_conversation([msg("user", t("q"), id="other"), msg("assistant", t("a"))])
|
|
102
|
+
c = build_conversation([msg("user", t("q")), msg("assistant", t("a!"))])
|
|
103
|
+
assert content_hash(a) == content_hash(b) != content_hash(c)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# -- project dir -----------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_project_dir_name_matches_claude_code():
|
|
110
|
+
assert project_dir_name("/Users/me/repos/my_app.v2") == "-Users-me-repos-my-app-v2"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_claude_project_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
114
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cfg"))
|
|
115
|
+
assert claude_project_dir("/a/b") == tmp_path / "cfg" / "projects" / "-a-b"
|
|
116
|
+
assert claude_project_dir("/a/b", transcript_path="/x/y/s.jsonl") == Path("/x/y")
|
|
117
|
+
assert claude_project_dir("/a/b", explicit="/z") == Path("/z")
|
|
118
|
+
long_root = "/" + "d" * 250
|
|
119
|
+
with pytest.raises(ImportFailedError):
|
|
120
|
+
claude_project_dir(long_root)
|
|
121
|
+
existing = (
|
|
122
|
+
tmp_path / "cfg" / "projects" / (project_dir_name(long_root)[:200] + "-abc")
|
|
123
|
+
)
|
|
124
|
+
existing.mkdir(parents=True)
|
|
125
|
+
assert claude_project_dir(long_root) == existing
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# -- import_session ----------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def session(**kw) -> Session:
|
|
132
|
+
base = {
|
|
133
|
+
"id": "ses_1",
|
|
134
|
+
"title": "Fix parser",
|
|
135
|
+
"directory": "/repo",
|
|
136
|
+
"time_updated": NOW,
|
|
137
|
+
}
|
|
138
|
+
base.update(kw)
|
|
139
|
+
return Session(**base)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
CONVO = [msg("user", t("fix the parser")), msg("assistant", t("fixed"))]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_import_writes_resumable_transcript(tmp_path: Path):
|
|
146
|
+
existing = existing_imports(tmp_path)
|
|
147
|
+
result = import_session(
|
|
148
|
+
session(),
|
|
149
|
+
CONVO,
|
|
150
|
+
project_dir=tmp_path,
|
|
151
|
+
cwd="/repo",
|
|
152
|
+
existing=existing,
|
|
153
|
+
git_branch="main",
|
|
154
|
+
)
|
|
155
|
+
assert result.status == "imported"
|
|
156
|
+
assert result.title == "[opencode] Fix parser"
|
|
157
|
+
assert result.path is not None and result.path.name == f"{result.claude_id}.jsonl"
|
|
158
|
+
entries = read_jsonl(result.path)
|
|
159
|
+
head, first, second = entries
|
|
160
|
+
assert head["type"] == "custom-title"
|
|
161
|
+
assert head["customTitle"] == "[opencode] Fix parser"
|
|
162
|
+
assert head["sessionId"] == result.claude_id
|
|
163
|
+
assert head[MARKER_KEY]["sessionId"] == "ses_1"
|
|
164
|
+
assert head[MARKER_KEY]["suffix"] == 0
|
|
165
|
+
assert first["type"] == "user" and first["parentUuid"] is None
|
|
166
|
+
assert first["message"]["content"].startswith(
|
|
167
|
+
"[Imported from opencode session ses_1"
|
|
168
|
+
)
|
|
169
|
+
assert first["message"]["content"].endswith("fix the parser")
|
|
170
|
+
assert first["cwd"] == "/repo" and first["gitBranch"] == "main"
|
|
171
|
+
assert second["type"] == "assistant"
|
|
172
|
+
assert second["parentUuid"] == first["uuid"]
|
|
173
|
+
assert second["message"]["model"] == "<synthetic>"
|
|
174
|
+
assert second["message"]["content"] == [{"type": "text", "text": "fixed"}]
|
|
175
|
+
assert not list(tmp_path.glob(".*.tmp"))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_unchanged_session_is_never_imported_twice(tmp_path: Path):
|
|
179
|
+
first = import_session(
|
|
180
|
+
session(),
|
|
181
|
+
CONVO,
|
|
182
|
+
project_dir=tmp_path,
|
|
183
|
+
cwd="/repo",
|
|
184
|
+
existing=existing_imports(tmp_path),
|
|
185
|
+
git_branch="",
|
|
186
|
+
)
|
|
187
|
+
again = import_session(
|
|
188
|
+
session(title="Renamed in opencode"),
|
|
189
|
+
CONVO,
|
|
190
|
+
project_dir=tmp_path,
|
|
191
|
+
cwd="/repo",
|
|
192
|
+
existing=existing_imports(tmp_path),
|
|
193
|
+
git_branch="",
|
|
194
|
+
)
|
|
195
|
+
assert again.status == "unchanged"
|
|
196
|
+
assert again.path == first.path
|
|
197
|
+
assert len(list(tmp_path.glob("*.jsonl"))) == 1
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_unchanged_detected_even_if_claude_appended_to_the_file(tmp_path: Path):
|
|
201
|
+
first = import_session(
|
|
202
|
+
session(),
|
|
203
|
+
CONVO,
|
|
204
|
+
project_dir=tmp_path,
|
|
205
|
+
cwd="/repo",
|
|
206
|
+
existing=existing_imports(tmp_path),
|
|
207
|
+
git_branch="",
|
|
208
|
+
)
|
|
209
|
+
with first.path.open("a") as handle: # user continued the conversation in Claude
|
|
210
|
+
handle.write(
|
|
211
|
+
json.dumps({"type": "user", "message": {"role": "user", "content": "more"}})
|
|
212
|
+
+ "\n"
|
|
213
|
+
)
|
|
214
|
+
again = import_session(
|
|
215
|
+
session(),
|
|
216
|
+
CONVO,
|
|
217
|
+
project_dir=tmp_path,
|
|
218
|
+
cwd="/repo",
|
|
219
|
+
existing=existing_imports(tmp_path),
|
|
220
|
+
git_branch="",
|
|
221
|
+
)
|
|
222
|
+
assert again.status == "unchanged"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_changed_session_gets_next_suffix(tmp_path: Path):
|
|
226
|
+
kwargs = {"project_dir": tmp_path, "cwd": "/repo", "git_branch": ""}
|
|
227
|
+
import_session(session(), CONVO, existing=existing_imports(tmp_path), **kwargs)
|
|
228
|
+
grown = [*CONVO, msg("user", t("one more thing")), msg("assistant", t("ok"))]
|
|
229
|
+
second = import_session(
|
|
230
|
+
session(), grown, existing=existing_imports(tmp_path), **kwargs
|
|
231
|
+
)
|
|
232
|
+
assert second.status == "imported"
|
|
233
|
+
assert second.title == "[opencode] Fix parser (1)"
|
|
234
|
+
grown2 = [*grown, msg("user", t("and another")), msg("assistant", t("sure"))]
|
|
235
|
+
third = import_session(
|
|
236
|
+
session(), grown2, existing=existing_imports(tmp_path), **kwargs
|
|
237
|
+
)
|
|
238
|
+
assert third.title == "[opencode] Fix parser (2)"
|
|
239
|
+
# Re-running with any of the three versions changes nothing.
|
|
240
|
+
for convo in (CONVO, grown, grown2):
|
|
241
|
+
again = import_session(
|
|
242
|
+
session(), convo, existing=existing_imports(tmp_path), **kwargs
|
|
243
|
+
)
|
|
244
|
+
assert again.status == "unchanged"
|
|
245
|
+
assert len(list(tmp_path.glob("*.jsonl"))) == 3
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_suffix_continues_after_the_highest(tmp_path: Path):
|
|
249
|
+
kwargs = {"project_dir": tmp_path, "cwd": "/repo", "git_branch": ""}
|
|
250
|
+
a = import_session(session(), CONVO, existing=existing_imports(tmp_path), **kwargs)
|
|
251
|
+
v1 = [*CONVO, msg("user", t("v1"))]
|
|
252
|
+
b = import_session(session(), v1, existing=existing_imports(tmp_path), **kwargs)
|
|
253
|
+
assert b.title.endswith("(1)")
|
|
254
|
+
a.path.unlink() # user deleted the original import
|
|
255
|
+
v2 = [*CONVO, msg("user", t("v2"))]
|
|
256
|
+
c = import_session(session(), v2, existing=existing_imports(tmp_path), **kwargs)
|
|
257
|
+
assert c.title.endswith("(2)")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_empty_and_dry_run(tmp_path: Path):
|
|
261
|
+
empty = import_session(
|
|
262
|
+
session(),
|
|
263
|
+
[msg("idle")],
|
|
264
|
+
project_dir=tmp_path,
|
|
265
|
+
cwd="/r",
|
|
266
|
+
existing={},
|
|
267
|
+
git_branch="",
|
|
268
|
+
)
|
|
269
|
+
assert empty.status == "empty"
|
|
270
|
+
dry = import_session(
|
|
271
|
+
session(),
|
|
272
|
+
CONVO,
|
|
273
|
+
project_dir=tmp_path,
|
|
274
|
+
cwd="/r",
|
|
275
|
+
existing={},
|
|
276
|
+
dry_run=True,
|
|
277
|
+
git_branch="",
|
|
278
|
+
)
|
|
279
|
+
assert dry.status == "would-import"
|
|
280
|
+
assert not list(tmp_path.iterdir())
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_conversation_is_padded_to_valid_alternation(tmp_path: Path):
|
|
284
|
+
result = import_session(
|
|
285
|
+
session(),
|
|
286
|
+
[msg("assistant", t("hello?"))],
|
|
287
|
+
project_dir=tmp_path,
|
|
288
|
+
cwd="/r",
|
|
289
|
+
existing={},
|
|
290
|
+
git_branch="",
|
|
291
|
+
)
|
|
292
|
+
roles = [e["type"] for e in read_jsonl(result.path)[1:]]
|
|
293
|
+
assert roles == ["user", "assistant"]
|
|
294
|
+
result = import_session(
|
|
295
|
+
session(id="ses_2"),
|
|
296
|
+
[msg("user", t("unanswered"))],
|
|
297
|
+
project_dir=tmp_path,
|
|
298
|
+
cwd="/r",
|
|
299
|
+
existing={},
|
|
300
|
+
git_branch="",
|
|
301
|
+
)
|
|
302
|
+
entries = read_jsonl(result.path)[1:]
|
|
303
|
+
assert [e["type"] for e in entries] == ["user", "assistant"]
|
|
304
|
+
assert "ended here" in entries[-1]["message"]["content"][0]["text"]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_existing_imports_ignores_foreign_files(tmp_path: Path):
|
|
308
|
+
(tmp_path / "claude-own.jsonl").write_text('{"type":"user","message":{}}\n')
|
|
309
|
+
(tmp_path / "broken.jsonl").write_text('{"opencodeImport": not json\n')
|
|
310
|
+
(tmp_path / "empty.jsonl").write_text("")
|
|
311
|
+
assert existing_imports(tmp_path) == {}
|
|
312
|
+
assert existing_imports(tmp_path / "missing") == {}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def test_never_overwrites_a_file_that_appears_meanwhile(tmp_path: Path, monkeypatch):
|
|
316
|
+
from claude_opencode_sessions import importer
|
|
317
|
+
|
|
318
|
+
result = import_session(
|
|
319
|
+
session(),
|
|
320
|
+
CONVO,
|
|
321
|
+
project_dir=tmp_path,
|
|
322
|
+
cwd="/r",
|
|
323
|
+
existing={},
|
|
324
|
+
dry_run=True,
|
|
325
|
+
git_branch="",
|
|
326
|
+
)
|
|
327
|
+
result.path.write_text("precious\n")
|
|
328
|
+
monkeypatch.setattr(importer.Path, "exists", lambda self: False)
|
|
329
|
+
again = import_session(
|
|
330
|
+
session(), CONVO, project_dir=tmp_path, cwd="/r", existing={}, git_branch=""
|
|
331
|
+
)
|
|
332
|
+
monkeypatch.undo()
|
|
333
|
+
assert again.status == "unchanged"
|
|
334
|
+
assert result.path.read_text() == "precious\n"
|
|
335
|
+
assert not list(tmp_path.glob(".*.tmp"))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# -- CLI + hook --------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@pytest.fixture
|
|
342
|
+
def repo_db(db_1x: DbBuilder, git_repo, monkeypatch: pytest.MonkeyPatch):
|
|
343
|
+
repo, feature = git_repo["repo"], git_repo["feature"]
|
|
344
|
+
db_1x.session_1x(
|
|
345
|
+
"ses_a",
|
|
346
|
+
str(repo),
|
|
347
|
+
title="Parser work",
|
|
348
|
+
updated=NOW,
|
|
349
|
+
messages=[
|
|
350
|
+
user("fix parser"),
|
|
351
|
+
assistant(text("done"), tool("edit", {"filePath": "p.py"})),
|
|
352
|
+
],
|
|
353
|
+
)
|
|
354
|
+
db_1x.session_1x(
|
|
355
|
+
"ses_b",
|
|
356
|
+
str(repo / "sub"),
|
|
357
|
+
title="Docs",
|
|
358
|
+
updated=NOW - HOUR,
|
|
359
|
+
messages=[user("write docs"), assistant(text("ok"))],
|
|
360
|
+
)
|
|
361
|
+
db_1x.session_1x("ses_empty", str(repo), updated=NOW - 2 * HOUR)
|
|
362
|
+
db_1x.session_1x("ses_child", str(repo), parent_id="ses_a", messages=[user("sub")])
|
|
363
|
+
db_1x.session_1x("ses_feat", str(feature), title="Feature", messages=[user("feat")])
|
|
364
|
+
db_1x.session_1x(
|
|
365
|
+
"ses_other",
|
|
366
|
+
"/elsewhere",
|
|
367
|
+
title="Other",
|
|
368
|
+
project_id="proj2",
|
|
369
|
+
messages=[user("x")],
|
|
370
|
+
)
|
|
371
|
+
db_1x.close()
|
|
372
|
+
monkeypatch.setenv("OPENCODE_SESSIONS_DB", str(db_1x.path))
|
|
373
|
+
return git_repo
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def test_cli_import_is_idempotent(repo_db, tmp_path: Path, capsys):
|
|
377
|
+
target = tmp_path / "claude-project"
|
|
378
|
+
argv = [
|
|
379
|
+
"import",
|
|
380
|
+
"--path",
|
|
381
|
+
str(repo_db["repo"]),
|
|
382
|
+
"--claude-project-dir",
|
|
383
|
+
str(target),
|
|
384
|
+
]
|
|
385
|
+
assert main([*argv, "--dry-run"]) == 0
|
|
386
|
+
out = capsys.readouterr().out
|
|
387
|
+
assert out.count("would-import") == 3 # a, b + the summary line
|
|
388
|
+
assert not target.exists()
|
|
389
|
+
assert main(argv) == 0
|
|
390
|
+
out = capsys.readouterr().out
|
|
391
|
+
assert "2 imported, 1 empty · skipped: 1 sub-agent" in out
|
|
392
|
+
assert "/resume" in out
|
|
393
|
+
assert len(list(target.glob("*.jsonl"))) == 2
|
|
394
|
+
assert main(argv) == 0
|
|
395
|
+
out = capsys.readouterr().out
|
|
396
|
+
assert "2 unchanged, 1 empty" in out
|
|
397
|
+
assert len(list(target.glob("*.jsonl"))) == 2
|
|
398
|
+
assert main([*argv, "--worktrees"]) == 0
|
|
399
|
+
assert "[opencode] Feature" in capsys.readouterr().out
|
|
400
|
+
assert len(list(target.glob("*.jsonl"))) == 3
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def test_cli_import_default_project_dir(repo_db, tmp_path: Path, monkeypatch, capsys):
|
|
404
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cfg"))
|
|
405
|
+
monkeypatch.chdir(repo_db["repo"])
|
|
406
|
+
assert main(["import"]) == 0
|
|
407
|
+
expected = tmp_path / "cfg" / "projects" / project_dir_name(str(repo_db["repo"]))
|
|
408
|
+
assert len(list(expected.glob("*.jsonl"))) == 2
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def hook_event(
|
|
412
|
+
repo: Path, transcript_dir: Path, name="opencode-sessions:import", args=""
|
|
413
|
+
):
|
|
414
|
+
return json.dumps(
|
|
415
|
+
{
|
|
416
|
+
"session_id": "s",
|
|
417
|
+
"transcript_path": str(transcript_dir / "current.jsonl"),
|
|
418
|
+
"cwd": str(repo),
|
|
419
|
+
"hook_event_name": "UserPromptExpansion",
|
|
420
|
+
"expansion_type": "slash_command",
|
|
421
|
+
"command_name": name,
|
|
422
|
+
"command_args": args,
|
|
423
|
+
"prompt": "…",
|
|
424
|
+
}
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def test_hook_imports_and_blocks_the_prompt(repo_db, tmp_path: Path, monkeypatch):
|
|
429
|
+
monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
|
|
430
|
+
target = tmp_path / "proj"
|
|
431
|
+
output = json.loads(hook_main(hook_event(repo_db["repo"], target)))
|
|
432
|
+
assert output["decision"] == "block"
|
|
433
|
+
assert "2 imported" in output["reason"]
|
|
434
|
+
assert len(list(target.glob("*.jsonl"))) == 2
|
|
435
|
+
output = json.loads(
|
|
436
|
+
hook_main(hook_event(repo_db["repo"], target, args="--dry-run"))
|
|
437
|
+
)
|
|
438
|
+
assert "2 unchanged" in output["reason"]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_hook_uses_command_input_and_project_dir_env(repo_db, tmp_path, monkeypatch):
|
|
442
|
+
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(repo_db["repo"]))
|
|
443
|
+
event = json.loads(
|
|
444
|
+
hook_event(Path("/somewhere/else"), tmp_path / "p", name="import")
|
|
445
|
+
)
|
|
446
|
+
del event["command_args"]
|
|
447
|
+
event["command_input"] = {"args": "--worktrees --dry-run"}
|
|
448
|
+
output = json.loads(hook_main(json.dumps(event)))
|
|
449
|
+
assert "[opencode] Feature" in output["reason"]
|
|
450
|
+
assert "Dry run" in output["reason"]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def test_hook_ignores_other_commands_and_garbage():
|
|
454
|
+
assert hook_main("not json") is None
|
|
455
|
+
assert hook_main("[]") is None
|
|
456
|
+
assert hook_main(json.dumps({"command_name": "commit"})) is None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def test_hook_reports_bad_arguments_and_errors(tmp_path, monkeypatch):
|
|
460
|
+
output = json.loads(hook_main(hook_event(tmp_path, tmp_path, args="--nope")))
|
|
461
|
+
assert output["decision"] == "block" and "Usage:" in output["reason"]
|
|
462
|
+
monkeypatch.setenv("OPENCODE_SESSIONS_DB", str(tmp_path / "missing.db"))
|
|
463
|
+
monkeypatch.setenv("PATH", str(tmp_path))
|
|
464
|
+
output = json.loads(
|
|
465
|
+
hook_main(hook_event(tmp_path, tmp_path, args="--backend sqlite"))
|
|
466
|
+
)
|
|
467
|
+
assert output["reason"].startswith("opencode import failed:")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def test_hook_via_cli_entrypoint(repo_db, tmp_path, monkeypatch, capsys):
|
|
471
|
+
import io
|
|
472
|
+
|
|
473
|
+
monkeypatch.setattr(
|
|
474
|
+
"sys.stdin", io.StringIO(hook_event(repo_db["repo"], tmp_path / "p"))
|
|
475
|
+
)
|
|
476
|
+
assert main(["hook"]) == 0
|
|
477
|
+
assert json.loads(capsys.readouterr().out)["decision"] == "block"
|
|
478
|
+
monkeypatch.setattr("sys.stdin", io.StringIO("{}"))
|
|
479
|
+
assert main(["hook"]) == 0
|
|
480
|
+
assert capsys.readouterr().out == ""
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def test_imported_transcript_is_valid_jsonl_with_unique_uuids(repo_db, tmp_path):
|
|
484
|
+
target = tmp_path / "p"
|
|
485
|
+
main(
|
|
486
|
+
["import", "--path", str(repo_db["repo"]), "--claude-project-dir", str(target)]
|
|
487
|
+
)
|
|
488
|
+
for path in target.glob("*.jsonl"):
|
|
489
|
+
entries = read_jsonl(path)
|
|
490
|
+
uuids = [e["uuid"] for e in entries if "uuid" in e]
|
|
491
|
+
assert len(uuids) == len(set(uuids))
|
|
492
|
+
parents = [e.get("parentUuid") for e in entries if "uuid" in e]
|
|
493
|
+
assert parents[0] is None and parents[1:] == uuids[:-1]
|
|
494
|
+
assert {e["sessionId"] for e in entries} == {path.stem}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def test_sqlite_errors_do_not_leak(monkeypatch, tmp_path):
|
|
498
|
+
bad = tmp_path / "bad.db"
|
|
499
|
+
sqlite3.connect(bad).execute("CREATE TABLE x (y)").connection.close()
|
|
500
|
+
monkeypatch.setenv("OPENCODE_SESSIONS_DB", str(bad))
|
|
501
|
+
monkeypatch.setenv("PATH", str(tmp_path))
|
|
502
|
+
output = json.loads(hook_main(hook_event(tmp_path, tmp_path)))
|
|
503
|
+
assert output["reason"].startswith("opencode import failed:")
|
|
504
|
+
assert os.listdir(tmp_path) == ["bad.db"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""The shell launcher used by the Claude Code plugin hook."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
LAUNCHER = Path(__file__).resolve().parents[3] / "scripts" / "opencode-sessions"
|
|
11
|
+
|
|
12
|
+
pytestmark = [
|
|
13
|
+
pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell launcher"),
|
|
14
|
+
pytest.mark.skipif(not LAUNCHER.exists(), reason="not running from a checkout"),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_launcher_runs_bundled_package():
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["sh", str(LAUNCHER), "--version"],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
check=False,
|
|
24
|
+
env={"PATH": "/usr/bin:/bin", "OCS_PYTHON": sys.executable},
|
|
25
|
+
)
|
|
26
|
+
assert result.returncode == 0, result.stderr
|
|
27
|
+
assert result.stdout.startswith("claude-opencode-sessions ")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_launcher_reports_missing_python(tmp_path: Path):
|
|
31
|
+
sh = shutil.which("sh")
|
|
32
|
+
assert sh
|
|
33
|
+
fake_bin = tmp_path / "bin"
|
|
34
|
+
fake_bin.mkdir()
|
|
35
|
+
for tool in ("dirname", "sh"):
|
|
36
|
+
found = shutil.which(tool)
|
|
37
|
+
if found:
|
|
38
|
+
(fake_bin / tool).symlink_to(found)
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
[sh, str(LAUNCHER), "list"],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
check=False,
|
|
44
|
+
env={"PATH": str(fake_bin)},
|
|
45
|
+
)
|
|
46
|
+
assert result.returncode == 5
|
|
47
|
+
assert "Python >= 3.10 is required" in result.stderr
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from claude_opencode_sessions.errors import DatabaseNotFoundError
|
|
7
|
+
from claude_opencode_sessions.locate import candidate_paths, find_db, opencode_db_path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture(autouse=True)
|
|
11
|
+
def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
|
|
12
|
+
monkeypatch.delenv("OPENCODE_SESSIONS_DB", raising=False)
|
|
13
|
+
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
|
|
14
|
+
monkeypatch.delenv("LOCALAPPDATA", raising=False)
|
|
15
|
+
monkeypatch.delenv("APPDATA", raising=False)
|
|
16
|
+
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_candidates_respect_xdg(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
|
|
20
|
+
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg"))
|
|
21
|
+
paths = candidate_paths()
|
|
22
|
+
assert paths[0] == tmp_path / "xdg" / "opencode" / "opencode.db"
|
|
23
|
+
assert (
|
|
24
|
+
paths[1] == tmp_path / "home" / ".local" / "share" / "opencode" / "opencode.db"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_find_db_default_location(tmp_path: Path):
|
|
29
|
+
db = tmp_path / "home" / ".local" / "share" / "opencode" / "opencode.db"
|
|
30
|
+
db.parent.mkdir(parents=True)
|
|
31
|
+
db.write_bytes(b"")
|
|
32
|
+
assert find_db(ask_cli=False) == db
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_find_db_explicit_and_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
|
|
36
|
+
db = tmp_path / "custom.db"
|
|
37
|
+
db.write_bytes(b"")
|
|
38
|
+
assert find_db(db) == db
|
|
39
|
+
with pytest.raises(DatabaseNotFoundError):
|
|
40
|
+
find_db(tmp_path / "missing.db")
|
|
41
|
+
monkeypatch.setenv("OPENCODE_SESSIONS_DB", str(db))
|
|
42
|
+
assert find_db() == db
|
|
43
|
+
monkeypatch.setenv("OPENCODE_SESSIONS_DB", str(tmp_path / "nope.db"))
|
|
44
|
+
with pytest.raises(DatabaseNotFoundError, match="OPENCODE_SESSIONS_DB"):
|
|
45
|
+
find_db()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_find_db_asks_opencode(fake_opencode: Path, tmp_path: Path):
|
|
49
|
+
db = tmp_path / "from-cli.db"
|
|
50
|
+
db.write_bytes(b"")
|
|
51
|
+
data = json.loads(fake_opencode.read_text())
|
|
52
|
+
data["db_path"] = str(db)
|
|
53
|
+
fake_opencode.write_text(json.dumps(data))
|
|
54
|
+
assert opencode_db_path() == db
|
|
55
|
+
assert find_db() == db
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_find_db_gives_up(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
|
|
59
|
+
monkeypatch.setenv("PATH", str(tmp_path))
|
|
60
|
+
assert opencode_db_path() is None
|
|
61
|
+
with pytest.raises(DatabaseNotFoundError, match="looked in"):
|
|
62
|
+
find_db()
|