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,10 @@
|
|
|
1
|
+
"""Import opencode sessions into Claude Code, scoped to the current repository."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("claude-opencode-sessions")
|
|
7
|
+
except PackageNotFoundError: # running from a source checkout, e.g. as the plugin
|
|
8
|
+
__version__ = "0+source"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Command line interface: ``claude-opencode-sessions``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import sqlite3
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .errors import OpencodeSessionsError
|
|
16
|
+
from .import_command import add_import_arguments, cmd_hook, cmd_import
|
|
17
|
+
from .locate import candidate_paths, find_db
|
|
18
|
+
from .render import render_list
|
|
19
|
+
from .scope import Scope, resolve_scope
|
|
20
|
+
from .service import BACKENDS, Context, open_context
|
|
21
|
+
|
|
22
|
+
__all__ = ["build_parser", "main"]
|
|
23
|
+
|
|
24
|
+
PROG = "claude-opencode-sessions"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _add_common(parser: argparse.ArgumentParser) -> None:
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--path",
|
|
30
|
+
help="directory to scope to (default: current directory)",
|
|
31
|
+
)
|
|
32
|
+
group = parser.add_mutually_exclusive_group()
|
|
33
|
+
group.add_argument(
|
|
34
|
+
"--worktrees",
|
|
35
|
+
dest="scope",
|
|
36
|
+
action="store_const",
|
|
37
|
+
const="repo",
|
|
38
|
+
help="include every worktree of the current git repository",
|
|
39
|
+
)
|
|
40
|
+
group.add_argument(
|
|
41
|
+
"--all",
|
|
42
|
+
dest="scope",
|
|
43
|
+
action="store_const",
|
|
44
|
+
const="all",
|
|
45
|
+
help="include sessions from every directory",
|
|
46
|
+
)
|
|
47
|
+
parser.set_defaults(scope="worktree")
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--include-archived", action="store_true", help="show archived sessions"
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--include-subagents", action="store_true", help="show sub-agent sessions"
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument("--format", choices=("md", "json"), default="md")
|
|
55
|
+
parser.add_argument("--backend", choices=BACKENDS, default="auto")
|
|
56
|
+
parser.add_argument("--db", help="path to opencode.db (default: auto-detect)")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
60
|
+
parser = argparse.ArgumentParser(
|
|
61
|
+
prog=PROG,
|
|
62
|
+
description=(
|
|
63
|
+
"Import opencode sessions for the current repository into Claude "
|
|
64
|
+
"Code (so they open with /resume), or list them. Scoping mirrors "
|
|
65
|
+
"Claude Code's /resume: current worktree by default, --worktrees "
|
|
66
|
+
"for all worktrees."
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
71
|
+
)
|
|
72
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
73
|
+
|
|
74
|
+
p_list = sub.add_parser("list", help="list sessions in scope, newest first")
|
|
75
|
+
_add_common(p_list)
|
|
76
|
+
p_list.add_argument(
|
|
77
|
+
"-n", "--limit", type=int, default=20, help="max rows (0 = all)"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
p_import = sub.add_parser(
|
|
81
|
+
"import",
|
|
82
|
+
help="import sessions into Claude Code (they then show up in /resume)",
|
|
83
|
+
)
|
|
84
|
+
add_import_arguments(p_import)
|
|
85
|
+
|
|
86
|
+
sub.add_parser(
|
|
87
|
+
"hook",
|
|
88
|
+
help="Claude Code UserPromptExpansion hook (reads the event from stdin)",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
p_doctor = sub.add_parser("doctor", help="diagnose environment and database")
|
|
92
|
+
p_doctor.add_argument("--path")
|
|
93
|
+
p_doctor.add_argument("--db")
|
|
94
|
+
return parser
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _emit(text: str) -> None:
|
|
98
|
+
sys.stdout.write(text)
|
|
99
|
+
sys.stdout.flush()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _scope(args: argparse.Namespace) -> Scope:
|
|
103
|
+
return resolve_scope(args.path, args.scope)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_list(args: argparse.Namespace, ctx: Context) -> int:
|
|
107
|
+
scope = _scope(args)
|
|
108
|
+
listing = ctx.listing(scope, args.include_archived, args.include_subagents)
|
|
109
|
+
shown = listing.shown[: args.limit] if args.limit > 0 else listing.shown
|
|
110
|
+
if args.format == "json":
|
|
111
|
+
payload = {
|
|
112
|
+
"scope": {"mode": scope.mode, "roots": scope.roots, "cwd": scope.cwd},
|
|
113
|
+
"backend": ctx.backend_name,
|
|
114
|
+
"total": len(listing.shown),
|
|
115
|
+
"hidden_archived": listing.hidden_archived,
|
|
116
|
+
"hidden_subagents": listing.hidden_subagents,
|
|
117
|
+
"sessions": [
|
|
118
|
+
{"index": i, **s.to_dict()} for i, s in enumerate(shown, start=1)
|
|
119
|
+
],
|
|
120
|
+
"notes": ctx.notes,
|
|
121
|
+
}
|
|
122
|
+
_emit(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
123
|
+
return 0
|
|
124
|
+
_emit(
|
|
125
|
+
render_list(
|
|
126
|
+
shown,
|
|
127
|
+
scope,
|
|
128
|
+
hidden_archived=listing.hidden_archived,
|
|
129
|
+
hidden_subagents=listing.hidden_subagents,
|
|
130
|
+
total=len(listing.shown),
|
|
131
|
+
backend=ctx.describe(),
|
|
132
|
+
notes=ctx.notes,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
139
|
+
lines = [
|
|
140
|
+
f"{PROG} {__version__}",
|
|
141
|
+
f"python: {sys.version.split()[0]} ({sys.executable})",
|
|
142
|
+
f"platform: {platform.platform()}",
|
|
143
|
+
]
|
|
144
|
+
opencode = shutil.which("opencode")
|
|
145
|
+
lines.append(f"opencode on PATH: {opencode or 'no'}")
|
|
146
|
+
lines.append("db candidates: " + ", ".join(str(p) for p in candidate_paths()))
|
|
147
|
+
status = 0
|
|
148
|
+
try:
|
|
149
|
+
db = find_db(args.db)
|
|
150
|
+
lines.append(f"db: {db} ({db.stat().st_size:,} bytes)")
|
|
151
|
+
from .sqlite_backend import SqliteBackend
|
|
152
|
+
|
|
153
|
+
backend = SqliteBackend(db)
|
|
154
|
+
lines.append(f"layout: {backend.describe()}")
|
|
155
|
+
for table, count in backend.table_report().items():
|
|
156
|
+
lines.append(f" {table}: {count:,} rows")
|
|
157
|
+
sessions = backend.list_sessions()
|
|
158
|
+
lines.append(f"sessions readable: {len(sessions)}")
|
|
159
|
+
backend.close()
|
|
160
|
+
except (OpencodeSessionsError, sqlite3.Error) as exc:
|
|
161
|
+
lines.append(f"db: NOT USABLE — {exc}")
|
|
162
|
+
status = 3
|
|
163
|
+
try:
|
|
164
|
+
for mode in ("worktree", "repo"):
|
|
165
|
+
scope = resolve_scope(args.path, mode)
|
|
166
|
+
lines.append(f"scope {mode}: {scope.describe()} roots={scope.roots}")
|
|
167
|
+
except OSError as exc: # pragma: no cover
|
|
168
|
+
lines.append(f"scope: error — {exc}")
|
|
169
|
+
lines.append(f"cwd: {os.getcwd()}")
|
|
170
|
+
_emit("\n".join(lines) + "\n")
|
|
171
|
+
return status
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
175
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
176
|
+
sys.stdout.reconfigure(errors="replace")
|
|
177
|
+
parser = build_parser()
|
|
178
|
+
args = parser.parse_args(argv)
|
|
179
|
+
try:
|
|
180
|
+
if args.command == "doctor":
|
|
181
|
+
return cmd_doctor(args)
|
|
182
|
+
if args.command == "hook":
|
|
183
|
+
return cmd_hook(args)
|
|
184
|
+
if args.command == "import":
|
|
185
|
+
return cmd_import(args)
|
|
186
|
+
ctx = open_context(args.backend, args.db)
|
|
187
|
+
return cmd_list(args, ctx)
|
|
188
|
+
except OpencodeSessionsError as exc:
|
|
189
|
+
print(f"{PROG}: {exc}", file=sys.stderr)
|
|
190
|
+
return exc.exit_code
|
|
191
|
+
except BrokenPipeError: # pragma: no cover
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__": # pragma: no cover
|
|
196
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Fallback backend that shells out to the ``opencode`` CLI.
|
|
2
|
+
|
|
3
|
+
Used when the database can't be read directly (missing, locked, or a schema
|
|
4
|
+
this package doesn't recognise):
|
|
5
|
+
|
|
6
|
+
* ``opencode session list --format json`` for listing,
|
|
7
|
+
* ``opencode export <id>`` for transcripts.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .errors import BackendError, NotFoundError
|
|
20
|
+
from .models import Message, Session, format_model
|
|
21
|
+
from .parse import as_dict, as_list, parse_v1_message
|
|
22
|
+
|
|
23
|
+
__all__ = ["MIN_VERSION", "CliBackend", "parse_version"]
|
|
24
|
+
|
|
25
|
+
MIN_VERSION = (1, 2)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_version(text: str) -> tuple[int, ...] | None:
|
|
29
|
+
match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", text)
|
|
30
|
+
if not match:
|
|
31
|
+
return None
|
|
32
|
+
return tuple(int(g) for g in match.groups() if g is not None)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _extract_json(text: str) -> Any:
|
|
36
|
+
"""Parse the first JSON document in ``text`` (skipping log lines)."""
|
|
37
|
+
decoder = json.JSONDecoder()
|
|
38
|
+
for index, char in enumerate(text):
|
|
39
|
+
if char in "[{":
|
|
40
|
+
try:
|
|
41
|
+
value, _ = decoder.raw_decode(text[index:])
|
|
42
|
+
except ValueError:
|
|
43
|
+
continue
|
|
44
|
+
return value
|
|
45
|
+
raise BackendError("opencode CLI did not return JSON")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get(obj: dict[str, Any], *keys: str) -> Any:
|
|
49
|
+
for key in keys:
|
|
50
|
+
if key in obj and obj[key] is not None:
|
|
51
|
+
return obj[key]
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def session_from_info(info: dict[str, Any]) -> Session:
|
|
56
|
+
time = as_dict(info.get("time"))
|
|
57
|
+
return Session(
|
|
58
|
+
id=str(info.get("id", "")),
|
|
59
|
+
title=str(info.get("title") or "(untitled)"),
|
|
60
|
+
directory=str(_get(info, "directory", "cwd", "path") or ""),
|
|
61
|
+
project_id=_get(info, "projectID", "project_id"),
|
|
62
|
+
parent_id=_get(info, "parentID", "parent_id"),
|
|
63
|
+
time_created=_get(time, "created") or _get(info, "time_created", "created"),
|
|
64
|
+
time_updated=_get(time, "updated") or _get(info, "time_updated", "updated"),
|
|
65
|
+
time_archived=_get(time, "archived") or _get(info, "time_archived"),
|
|
66
|
+
agent=info.get("agent"),
|
|
67
|
+
model=format_model(info.get("model")),
|
|
68
|
+
version=info.get("version"),
|
|
69
|
+
origin="cli",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CliBackend:
|
|
74
|
+
name = "cli"
|
|
75
|
+
|
|
76
|
+
def __init__(self, executable: str = "opencode", timeout: float = 120) -> None:
|
|
77
|
+
exe = shutil.which(executable)
|
|
78
|
+
if not exe:
|
|
79
|
+
raise BackendError(f"`{executable}` not found on PATH")
|
|
80
|
+
self.exe = exe
|
|
81
|
+
self.timeout = timeout
|
|
82
|
+
version_text = self._run(["--version"]).strip()
|
|
83
|
+
self.version = parse_version(version_text)
|
|
84
|
+
if self.version is None or self.version[:2] < MIN_VERSION:
|
|
85
|
+
raise BackendError(
|
|
86
|
+
f"opencode {version_text or '?'} is too old; "
|
|
87
|
+
f"need >= {'.'.join(map(str, MIN_VERSION))}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _run(self, args: list[str], cwd: str | None = None) -> str:
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
[self.exe, *args],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=self.timeout,
|
|
97
|
+
cwd=cwd,
|
|
98
|
+
check=False,
|
|
99
|
+
)
|
|
100
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
101
|
+
raise BackendError(f"opencode {' '.join(args)} failed: {exc}") from exc
|
|
102
|
+
if result.returncode != 0:
|
|
103
|
+
detail = (result.stderr or result.stdout).strip().splitlines()
|
|
104
|
+
raise BackendError(
|
|
105
|
+
f"opencode {' '.join(args)} exited {result.returncode}: "
|
|
106
|
+
f"{detail[-1] if detail else ''}"
|
|
107
|
+
)
|
|
108
|
+
return result.stdout
|
|
109
|
+
|
|
110
|
+
def describe(self) -> str:
|
|
111
|
+
return f"opencode CLI {'.'.join(map(str, self.version or ()))}"
|
|
112
|
+
|
|
113
|
+
def list_sessions(self, cwd: str | None = None) -> list[Session]:
|
|
114
|
+
workdir = cwd if cwd and os.path.isdir(cwd) else None
|
|
115
|
+
data = _extract_json(
|
|
116
|
+
self._run(["session", "list", "--format", "json"], cwd=workdir)
|
|
117
|
+
)
|
|
118
|
+
if isinstance(data, dict):
|
|
119
|
+
data = data.get("sessions") or data.get("data") or []
|
|
120
|
+
if not isinstance(data, list):
|
|
121
|
+
raise BackendError("unexpected `opencode session list` output")
|
|
122
|
+
return [session_from_info(item) for item in data if isinstance(item, dict)]
|
|
123
|
+
|
|
124
|
+
def export(self, session_id: str) -> tuple[Session | None, list[Message]]:
|
|
125
|
+
data = _extract_json(self._run(["export", session_id]))
|
|
126
|
+
if not isinstance(data, dict):
|
|
127
|
+
raise NotFoundError(f"opencode export {session_id}: unexpected output")
|
|
128
|
+
info = data.get("info")
|
|
129
|
+
session = session_from_info(info) if isinstance(info, dict) else None
|
|
130
|
+
messages: list[Message] = []
|
|
131
|
+
for index, item in enumerate(data.get("messages") or [], start=1):
|
|
132
|
+
if not isinstance(item, dict):
|
|
133
|
+
continue
|
|
134
|
+
parts = as_list(item.get("parts"))
|
|
135
|
+
messages.append(parse_v1_message(item.get("info"), parts, index))
|
|
136
|
+
return session, messages
|
|
137
|
+
|
|
138
|
+
def load_messages(self, session_id: str) -> tuple[list[Message], int]:
|
|
139
|
+
_, messages = self.export(session_id)
|
|
140
|
+
return messages, 0
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Exceptions shared by the backends and the CLI."""
|
|
2
|
+
|
|
3
|
+
__all__ = [
|
|
4
|
+
"BackendError",
|
|
5
|
+
"DatabaseNotFoundError",
|
|
6
|
+
"NotFoundError",
|
|
7
|
+
"OpencodeSessionsError",
|
|
8
|
+
"SchemaError",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpencodeSessionsError(Exception):
|
|
13
|
+
"""Base class; ``exit_code`` is used by the CLI."""
|
|
14
|
+
|
|
15
|
+
exit_code = 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NotFoundError(OpencodeSessionsError):
|
|
19
|
+
"""A session reference did not match anything."""
|
|
20
|
+
|
|
21
|
+
exit_code = 2
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DatabaseNotFoundError(OpencodeSessionsError):
|
|
25
|
+
"""No opencode database could be located."""
|
|
26
|
+
|
|
27
|
+
exit_code = 3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SchemaError(OpencodeSessionsError):
|
|
31
|
+
"""The database exists but does not look like a supported opencode DB."""
|
|
32
|
+
|
|
33
|
+
exit_code = 3
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BackendError(OpencodeSessionsError):
|
|
37
|
+
"""No backend could serve the request."""
|
|
38
|
+
|
|
39
|
+
exit_code = 4
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""``import`` command and the Claude Code ``UserPromptExpansion`` hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import sqlite3
|
|
10
|
+
import sys
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .errors import OpencodeSessionsError
|
|
17
|
+
from .importer import (
|
|
18
|
+
ImportResult,
|
|
19
|
+
claude_project_dir,
|
|
20
|
+
existing_imports,
|
|
21
|
+
import_session,
|
|
22
|
+
)
|
|
23
|
+
from .render import cell, tilde
|
|
24
|
+
from .scope import Scope, resolve_scope
|
|
25
|
+
from .service import Context, open_context
|
|
26
|
+
|
|
27
|
+
__all__ = ["add_import_arguments", "hook_main", "render_report", "run_import"]
|
|
28
|
+
|
|
29
|
+
COMMAND_NAMES = {"import", "opencode-sessions:import"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def add_import_arguments(parser: argparse.ArgumentParser) -> None:
|
|
33
|
+
parser.add_argument("--path", help="project directory (default: current directory)")
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--worktrees",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="include sessions from every worktree of the repository",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--dry-run", action="store_true", help="show what would be imported"
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--with-tool-output",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="include (truncated) tool output in the imported conversation",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument("--include-archived", action="store_true")
|
|
48
|
+
parser.add_argument("--include-subagents", action="store_true")
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--claude-project-dir",
|
|
51
|
+
help="Claude Code transcript directory to import into "
|
|
52
|
+
"(default: derived from the project path)",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument("--backend", choices=("auto", "sqlite", "cli"), default="auto")
|
|
55
|
+
parser.add_argument("--db", help="path to opencode.db (default: auto-detect)")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_import(
|
|
59
|
+
ctx: Context,
|
|
60
|
+
scope: Scope,
|
|
61
|
+
*,
|
|
62
|
+
project_dir: Path,
|
|
63
|
+
cwd: str,
|
|
64
|
+
dry_run: bool = False,
|
|
65
|
+
with_tool_output: bool = False,
|
|
66
|
+
include_archived: bool = False,
|
|
67
|
+
include_subagents: bool = False,
|
|
68
|
+
) -> tuple[list[ImportResult], dict[str, int]]:
|
|
69
|
+
listing = ctx.listing(scope, include_archived, include_subagents)
|
|
70
|
+
existing = existing_imports(project_dir)
|
|
71
|
+
results = []
|
|
72
|
+
for session in reversed(listing.shown): # oldest first
|
|
73
|
+
messages = ctx.messages(session)
|
|
74
|
+
results.append(
|
|
75
|
+
import_session(
|
|
76
|
+
session,
|
|
77
|
+
messages,
|
|
78
|
+
project_dir=project_dir,
|
|
79
|
+
cwd=cwd,
|
|
80
|
+
existing=existing,
|
|
81
|
+
with_tool_output=with_tool_output,
|
|
82
|
+
dry_run=dry_run,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
hidden = {
|
|
86
|
+
"archived": listing.hidden_archived,
|
|
87
|
+
"sub-agent": listing.hidden_subagents,
|
|
88
|
+
}
|
|
89
|
+
return results, hidden
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def render_report(
|
|
93
|
+
results: list[ImportResult],
|
|
94
|
+
hidden: dict[str, int],
|
|
95
|
+
scope: Scope,
|
|
96
|
+
project_dir: Path,
|
|
97
|
+
notes: Sequence[str] = (),
|
|
98
|
+
) -> str:
|
|
99
|
+
lines = [
|
|
100
|
+
f"opencode → Claude Code import · {scope.describe()}",
|
|
101
|
+
f"into {tilde(str(project_dir))}",
|
|
102
|
+
"",
|
|
103
|
+
]
|
|
104
|
+
if not results:
|
|
105
|
+
lines.append("No opencode sessions found for this project.")
|
|
106
|
+
for result in reversed(results): # newest first, like /resume
|
|
107
|
+
label = result.title or f"[opencode] {result.session.title}"
|
|
108
|
+
if result.status == "empty":
|
|
109
|
+
label += " (no conversation text)"
|
|
110
|
+
lines.append(f" {result.status:<12} {cell(label, 80)}")
|
|
111
|
+
counts = Counter(r.status for r in results)
|
|
112
|
+
order = ("imported", "would-import", "unchanged", "empty")
|
|
113
|
+
summary = ", ".join(f"{counts[k]} {k}" for k in order if counts.get(k))
|
|
114
|
+
extra = ", ".join(f"{n} {kind}" for kind, n in hidden.items() if n)
|
|
115
|
+
lines.append("")
|
|
116
|
+
if summary:
|
|
117
|
+
lines.append(summary + (f" · skipped: {extra}" if extra else ""))
|
|
118
|
+
lines.extend(f"note: {note}" for note in notes)
|
|
119
|
+
if counts.get("imported"):
|
|
120
|
+
lines.append('Open them with /resume — titles start with "[opencode]".')
|
|
121
|
+
elif counts.get("would-import"):
|
|
122
|
+
lines.append("Dry run: nothing was written.")
|
|
123
|
+
return "\n".join(lines) + "\n"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_import(args: argparse.Namespace) -> int:
|
|
127
|
+
ctx = open_context(args.backend, args.db)
|
|
128
|
+
scope = resolve_scope(args.path, "repo" if args.worktrees else "worktree")
|
|
129
|
+
cwd = scope.cwd
|
|
130
|
+
project_dir = claude_project_dir(cwd, explicit=args.claude_project_dir)
|
|
131
|
+
results, hidden = run_import(
|
|
132
|
+
ctx,
|
|
133
|
+
scope,
|
|
134
|
+
project_dir=project_dir,
|
|
135
|
+
cwd=cwd,
|
|
136
|
+
dry_run=args.dry_run,
|
|
137
|
+
with_tool_output=args.with_tool_output,
|
|
138
|
+
include_archived=args.include_archived,
|
|
139
|
+
include_subagents=args.include_subagents,
|
|
140
|
+
)
|
|
141
|
+
sys.stdout.write(render_report(results, hidden, scope, project_dir, ctx.notes))
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _hook_argv(data: dict[str, Any]) -> list[str]:
|
|
146
|
+
raw: Any = data.get("command_args")
|
|
147
|
+
if raw is None:
|
|
148
|
+
raw = data.get("command_input")
|
|
149
|
+
if isinstance(raw, dict):
|
|
150
|
+
raw = " ".join(str(v) for v in raw.values() if isinstance(v, (str, int)))
|
|
151
|
+
if not isinstance(raw, str):
|
|
152
|
+
return []
|
|
153
|
+
try:
|
|
154
|
+
return shlex.split(raw)
|
|
155
|
+
except ValueError:
|
|
156
|
+
return raw.split()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def hook_main(stdin: str) -> str | None:
|
|
160
|
+
"""Handle a ``UserPromptExpansion`` event; return the JSON to print.
|
|
161
|
+
|
|
162
|
+
Returns ``None`` for commands that aren't ours (the hook then stays
|
|
163
|
+
silent and Claude Code proceeds normally).
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
data = json.loads(stdin)
|
|
167
|
+
except ValueError:
|
|
168
|
+
return None
|
|
169
|
+
if not isinstance(data, dict):
|
|
170
|
+
return None
|
|
171
|
+
if str(data.get("command_name", "")) not in COMMAND_NAMES:
|
|
172
|
+
return None
|
|
173
|
+
parser = argparse.ArgumentParser(prog="/opencode-sessions:import", add_help=False)
|
|
174
|
+
add_import_arguments(parser)
|
|
175
|
+
try:
|
|
176
|
+
args = parser.parse_args(_hook_argv(data))
|
|
177
|
+
except SystemExit:
|
|
178
|
+
reason = "Usage: " + parser.format_usage().replace("usage: ", "")
|
|
179
|
+
return json.dumps({"decision": "block", "reason": reason})
|
|
180
|
+
cwd = (
|
|
181
|
+
args.path
|
|
182
|
+
or os.environ.get("CLAUDE_PROJECT_DIR")
|
|
183
|
+
or str(data.get("cwd") or "")
|
|
184
|
+
or os.getcwd()
|
|
185
|
+
)
|
|
186
|
+
try:
|
|
187
|
+
ctx = open_context(args.backend, args.db)
|
|
188
|
+
scope = resolve_scope(cwd, "repo" if args.worktrees else "worktree")
|
|
189
|
+
project_dir = claude_project_dir(
|
|
190
|
+
cwd,
|
|
191
|
+
transcript_path=None
|
|
192
|
+
if args.claude_project_dir
|
|
193
|
+
else data.get("transcript_path"),
|
|
194
|
+
explicit=args.claude_project_dir,
|
|
195
|
+
)
|
|
196
|
+
results, hidden = run_import(
|
|
197
|
+
ctx,
|
|
198
|
+
scope,
|
|
199
|
+
project_dir=project_dir,
|
|
200
|
+
cwd=cwd,
|
|
201
|
+
dry_run=args.dry_run,
|
|
202
|
+
with_tool_output=args.with_tool_output,
|
|
203
|
+
include_archived=args.include_archived,
|
|
204
|
+
include_subagents=args.include_subagents,
|
|
205
|
+
)
|
|
206
|
+
reason = render_report(results, hidden, scope, project_dir, ctx.notes)
|
|
207
|
+
except (OpencodeSessionsError, sqlite3.Error, OSError) as exc:
|
|
208
|
+
reason = f"opencode import failed: {exc}"
|
|
209
|
+
return json.dumps({"decision": "block", "reason": reason.rstrip()})
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cmd_hook(_args: argparse.Namespace) -> int:
|
|
213
|
+
output = hook_main(sys.stdin.read())
|
|
214
|
+
if output is not None:
|
|
215
|
+
sys.stdout.write(output + "\n")
|
|
216
|
+
return 0
|