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,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,3 @@
1
+ from claude_opencode_sessions.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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