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.
Files changed (33) hide show
  1. claude_opencode_sessions/__init__.py +10 -0
  2. claude_opencode_sessions/__main__.py +3 -0
  3. claude_opencode_sessions/cli.py +196 -0
  4. claude_opencode_sessions/cli_backend.py +140 -0
  5. claude_opencode_sessions/errors.py +39 -0
  6. claude_opencode_sessions/import_command.py +216 -0
  7. claude_opencode_sessions/importer.py +414 -0
  8. claude_opencode_sessions/locate.py +90 -0
  9. claude_opencode_sessions/models.py +96 -0
  10. claude_opencode_sessions/parse.py +224 -0
  11. claude_opencode_sessions/py.typed +0 -0
  12. claude_opencode_sessions/render.py +169 -0
  13. claude_opencode_sessions/scope.py +160 -0
  14. claude_opencode_sessions/service.py +149 -0
  15. claude_opencode_sessions/sqlite_backend.py +249 -0
  16. claude_opencode_sessions/tests/__init__.py +0 -0
  17. claude_opencode_sessions/tests/conftest.py +314 -0
  18. claude_opencode_sessions/tests/fixtures/schema_1x.sql +76 -0
  19. claude_opencode_sessions/tests/fixtures/schema_2x.sql +37 -0
  20. claude_opencode_sessions/tests/test_cli.py +140 -0
  21. claude_opencode_sessions/tests/test_cli_backend.py +116 -0
  22. claude_opencode_sessions/tests/test_importer.py +504 -0
  23. claude_opencode_sessions/tests/test_launcher.py +47 -0
  24. claude_opencode_sessions/tests/test_locate.py +62 -0
  25. claude_opencode_sessions/tests/test_parse.py +120 -0
  26. claude_opencode_sessions/tests/test_render.py +107 -0
  27. claude_opencode_sessions/tests/test_scope.py +121 -0
  28. claude_opencode_sessions/tests/test_sqlite_backend.py +157 -0
  29. claude_opencode_sessions-0.1.0.dist-info/METADATA +287 -0
  30. claude_opencode_sessions-0.1.0.dist-info/RECORD +33 -0
  31. claude_opencode_sessions-0.1.0.dist-info/WHEEL +4 -0
  32. claude_opencode_sessions-0.1.0.dist-info/entry_points.txt +2 -0
  33. 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()