codex2opencode 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.
- codex2opencode/__init__.py +1 -0
- codex2opencode/__main__.py +3 -0
- codex2opencode/cli.py +692 -0
- codex2opencode/errors.py +36 -0
- codex2opencode/event_stream.py +58 -0
- codex2opencode/locking.py +32 -0
- codex2opencode/logging_utils.py +20 -0
- codex2opencode/models.py +84 -0
- codex2opencode/opencode_cli.py +86 -0
- codex2opencode/paths.py +46 -0
- codex2opencode/state.py +31 -0
- codex2opencode/threading.py +18 -0
- codex2opencode/version.py +1 -0
- codex2opencode-0.1.0.dist-info/METADATA +279 -0
- codex2opencode-0.1.0.dist-info/RECORD +19 -0
- codex2opencode-0.1.0.dist-info/WHEEL +5 -0
- codex2opencode-0.1.0.dist-info/entry_points.txt +2 -0
- codex2opencode-0.1.0.dist-info/licenses/LICENSE +21 -0
- codex2opencode-0.1.0.dist-info/top_level.txt +1 -0
codex2opencode/errors.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
EXIT_OK = 0
|
|
2
|
+
EXIT_OPENCODE_ERROR = 1
|
|
3
|
+
EXIT_TIMEOUT = 2
|
|
4
|
+
EXIT_LOCK_CONFLICT = 3
|
|
5
|
+
EXIT_INVALID_ARGS = 4
|
|
6
|
+
EXIT_STATE_ERROR = 5
|
|
7
|
+
EXIT_STREAM_ERROR = 6
|
|
8
|
+
EXIT_SESSION_ERROR = 7
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BridgeError(Exception):
|
|
12
|
+
exit_code = EXIT_OPENCODE_ERROR
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TimeoutError(BridgeError):
|
|
16
|
+
exit_code = EXIT_TIMEOUT
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LockConflictError(BridgeError):
|
|
20
|
+
exit_code = EXIT_LOCK_CONFLICT
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidArgsError(BridgeError):
|
|
24
|
+
exit_code = EXIT_INVALID_ARGS
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StateError(BridgeError):
|
|
28
|
+
exit_code = EXIT_STATE_ERROR
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StreamError(BridgeError):
|
|
32
|
+
exit_code = EXIT_STREAM_ERROR
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SessionError(BridgeError):
|
|
36
|
+
exit_code = EXIT_SESSION_ERROR
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Iterable, Iterator, TextIO
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class StreamSummary:
|
|
10
|
+
session_id: str | None
|
|
11
|
+
text_output: str
|
|
12
|
+
malformed_lines: int
|
|
13
|
+
event_counts: dict[str, int]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_event_lines(lines: Iterable[str]) -> StreamSummary:
|
|
17
|
+
session_id: str | None = None
|
|
18
|
+
text_parts: list[str] = []
|
|
19
|
+
malformed_lines = 0
|
|
20
|
+
event_counts: dict[str, int] = {}
|
|
21
|
+
|
|
22
|
+
for raw_line in lines:
|
|
23
|
+
line = raw_line.strip()
|
|
24
|
+
if not line:
|
|
25
|
+
continue
|
|
26
|
+
try:
|
|
27
|
+
event = json.loads(line)
|
|
28
|
+
except json.JSONDecodeError:
|
|
29
|
+
malformed_lines += 1
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
if not isinstance(event, dict):
|
|
33
|
+
malformed_lines += 1
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
event_type = str(event.get("type", "unknown"))
|
|
37
|
+
event_counts[event_type] = event_counts.get(event_type, 0) + 1
|
|
38
|
+
if session_id is None:
|
|
39
|
+
candidate_session_id = event.get("sessionID")
|
|
40
|
+
if isinstance(candidate_session_id, str):
|
|
41
|
+
session_id = candidate_session_id
|
|
42
|
+
part = event.get("part") or {}
|
|
43
|
+
if event_type == "text" and isinstance(part, dict):
|
|
44
|
+
text = part.get("text")
|
|
45
|
+
if isinstance(text, str):
|
|
46
|
+
text_parts.append(text)
|
|
47
|
+
|
|
48
|
+
return StreamSummary(
|
|
49
|
+
session_id=session_id,
|
|
50
|
+
text_output="".join(text_parts),
|
|
51
|
+
malformed_lines=malformed_lines,
|
|
52
|
+
event_counts=event_counts,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def iter_stream_events(handle: TextIO) -> Iterator[str]:
|
|
57
|
+
for line in handle:
|
|
58
|
+
yield line
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .errors import LockConflictError
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import fcntl
|
|
10
|
+
except ImportError: # pragma: no cover
|
|
11
|
+
fcntl = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@contextmanager
|
|
15
|
+
def acquire_thread_lock(lock_path: Path):
|
|
16
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
handle = lock_path.open("a+", encoding="utf-8")
|
|
18
|
+
try:
|
|
19
|
+
if fcntl is None:
|
|
20
|
+
raise LockConflictError("Locking unsupported on this platform.")
|
|
21
|
+
try:
|
|
22
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
23
|
+
except BlockingIOError as exc:
|
|
24
|
+
raise LockConflictError("Lock already held.") from exc
|
|
25
|
+
yield lock_path
|
|
26
|
+
finally:
|
|
27
|
+
if fcntl is not None:
|
|
28
|
+
try:
|
|
29
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
handle.close()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from .paths import logs_dir
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def utc_now_iso() -> str:
|
|
10
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def append_bridge_log(payload: dict[str, object]) -> None:
|
|
14
|
+
log_path = logs_dir() / "bridge.log"
|
|
15
|
+
try:
|
|
16
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
with log_path.open("a", encoding="utf-8") as handle:
|
|
18
|
+
handle.write(json.dumps(payload, sort_keys=True) + "\n")
|
|
19
|
+
except OSError:
|
|
20
|
+
return
|
codex2opencode/models.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, asdict
|
|
4
|
+
|
|
5
|
+
from .errors import StateError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class ThreadState:
|
|
10
|
+
thread_key: str
|
|
11
|
+
workspace_root: str
|
|
12
|
+
thread_name: str | None
|
|
13
|
+
opencode_session_id: str | None
|
|
14
|
+
opencode_title: str | None
|
|
15
|
+
created_at: str
|
|
16
|
+
last_used_at: str
|
|
17
|
+
last_status: str
|
|
18
|
+
last_run_mode: str | None
|
|
19
|
+
bridge_version: str
|
|
20
|
+
opencode_version: str | None
|
|
21
|
+
last_error: str | None
|
|
22
|
+
message_count: int
|
|
23
|
+
last_exported_at: str | None
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, object]:
|
|
26
|
+
return asdict(self)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_dict(cls, data: dict[str, object]) -> "ThreadState":
|
|
30
|
+
required = {
|
|
31
|
+
"thread_key",
|
|
32
|
+
"workspace_root",
|
|
33
|
+
"thread_name",
|
|
34
|
+
"opencode_session_id",
|
|
35
|
+
"opencode_title",
|
|
36
|
+
"created_at",
|
|
37
|
+
"last_used_at",
|
|
38
|
+
"last_status",
|
|
39
|
+
"last_run_mode",
|
|
40
|
+
"bridge_version",
|
|
41
|
+
"opencode_version",
|
|
42
|
+
"last_error",
|
|
43
|
+
"message_count",
|
|
44
|
+
"last_exported_at",
|
|
45
|
+
}
|
|
46
|
+
missing = required - set(data.keys())
|
|
47
|
+
if missing:
|
|
48
|
+
raise StateError(f"Missing required state fields: {sorted(missing)}")
|
|
49
|
+
|
|
50
|
+
def expect_str(value: object, name: str) -> str:
|
|
51
|
+
if not isinstance(value, str):
|
|
52
|
+
raise StateError(f"Invalid type for {name}.")
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
def expect_optional_str(value: object, name: str) -> str | None:
|
|
56
|
+
if value is None:
|
|
57
|
+
return None
|
|
58
|
+
if not isinstance(value, str):
|
|
59
|
+
raise StateError(f"Invalid type for {name}.")
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
def expect_int(value: object, name: str) -> int:
|
|
63
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
64
|
+
raise StateError(f"Invalid type for {name}.")
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
return cls(
|
|
68
|
+
thread_key=expect_str(data["thread_key"], "thread_key"),
|
|
69
|
+
workspace_root=expect_str(data["workspace_root"], "workspace_root"),
|
|
70
|
+
thread_name=expect_optional_str(data["thread_name"], "thread_name"),
|
|
71
|
+
opencode_session_id=expect_optional_str(
|
|
72
|
+
data["opencode_session_id"], "opencode_session_id"
|
|
73
|
+
),
|
|
74
|
+
opencode_title=expect_optional_str(data["opencode_title"], "opencode_title"),
|
|
75
|
+
created_at=expect_str(data["created_at"], "created_at"),
|
|
76
|
+
last_used_at=expect_str(data["last_used_at"], "last_used_at"),
|
|
77
|
+
last_status=expect_str(data["last_status"], "last_status"),
|
|
78
|
+
last_run_mode=expect_optional_str(data["last_run_mode"], "last_run_mode"),
|
|
79
|
+
bridge_version=expect_str(data["bridge_version"], "bridge_version"),
|
|
80
|
+
opencode_version=expect_optional_str(data["opencode_version"], "opencode_version"),
|
|
81
|
+
last_error=expect_optional_str(data["last_error"], "last_error"),
|
|
82
|
+
message_count=expect_int(data["message_count"], "message_count"),
|
|
83
|
+
last_exported_at=expect_optional_str(data["last_exported_at"], "last_exported_at"),
|
|
84
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from codex2opencode.errors import InvalidArgsError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_run_command(
|
|
11
|
+
prompt: str,
|
|
12
|
+
cwd: str,
|
|
13
|
+
session_id: str | None,
|
|
14
|
+
fork: bool,
|
|
15
|
+
title: str | None,
|
|
16
|
+
opencode_bin: str = "opencode",
|
|
17
|
+
) -> list[str]:
|
|
18
|
+
if fork and not session_id:
|
|
19
|
+
raise InvalidArgsError("fork requires an existing session id")
|
|
20
|
+
command = [opencode_bin, "run", prompt, "--format", "json", "--dir", cwd]
|
|
21
|
+
if session_id:
|
|
22
|
+
command.extend(["--session", session_id])
|
|
23
|
+
if fork:
|
|
24
|
+
command.append("--fork")
|
|
25
|
+
if title:
|
|
26
|
+
command.extend(["--title", title])
|
|
27
|
+
return command
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_export_command(session_id: str, opencode_bin: str = "opencode") -> list[str]:
|
|
31
|
+
return [opencode_bin, "export", session_id]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_delete_session_command(session_id: str, opencode_bin: str = "opencode") -> list[str]:
|
|
35
|
+
return [opencode_bin, "session", "delete", session_id]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _run_command(command: list[str], cwd: str | None = None) -> subprocess.CompletedProcess[str] | None:
|
|
39
|
+
try:
|
|
40
|
+
return subprocess.run(
|
|
41
|
+
command,
|
|
42
|
+
check=False,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
cwd=cwd,
|
|
46
|
+
)
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
return None
|
|
49
|
+
except OSError:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_opencode_version(opencode_bin: str = "opencode", cwd: str | None = None) -> str | None:
|
|
54
|
+
completed = _run_command([opencode_bin, "--version"], cwd=cwd)
|
|
55
|
+
if not completed or completed.returncode != 0:
|
|
56
|
+
return None
|
|
57
|
+
output = completed.stdout.strip()
|
|
58
|
+
if not output:
|
|
59
|
+
return None
|
|
60
|
+
return output
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def read_debug_paths(opencode_bin: str = "opencode", cwd: str | None = None) -> str | None:
|
|
64
|
+
completed = _run_command([opencode_bin, "debug", "paths"], cwd=cwd)
|
|
65
|
+
if not completed or completed.returncode != 0:
|
|
66
|
+
return None
|
|
67
|
+
output = completed.stdout.strip()
|
|
68
|
+
if not output:
|
|
69
|
+
return None
|
|
70
|
+
return output
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_debug_config(opencode_bin: str = "opencode", cwd: str | None = None) -> dict[str, Any] | None:
|
|
74
|
+
completed = _run_command([opencode_bin, "debug", "config"], cwd=cwd)
|
|
75
|
+
if not completed or completed.returncode != 0:
|
|
76
|
+
return None
|
|
77
|
+
output = completed.stdout.strip()
|
|
78
|
+
if not output:
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
parsed = json.loads(output)
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
return None
|
|
84
|
+
if not isinstance(parsed, dict):
|
|
85
|
+
return None
|
|
86
|
+
return parsed
|
codex2opencode/paths.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .errors import InvalidArgsError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def bridge_root() -> Path:
|
|
10
|
+
override = os.environ.get("CODEX2OPENCODE_HOME")
|
|
11
|
+
if override:
|
|
12
|
+
return Path(override).expanduser()
|
|
13
|
+
return Path.home() / ".codex" / "codex2opencode"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def threads_dir() -> Path:
|
|
17
|
+
return bridge_root() / "threads"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def runs_dir() -> Path:
|
|
21
|
+
return bridge_root() / "runs"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def logs_dir() -> Path:
|
|
25
|
+
return bridge_root() / "logs"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_thread_key(thread_key: str) -> None:
|
|
29
|
+
if not thread_key or thread_key.strip() == "":
|
|
30
|
+
raise InvalidArgsError("Thread key must be non-empty.")
|
|
31
|
+
if os.sep in thread_key:
|
|
32
|
+
raise InvalidArgsError("Thread key contains path separators.")
|
|
33
|
+
if os.altsep and os.altsep in thread_key:
|
|
34
|
+
raise InvalidArgsError("Thread key contains path separators.")
|
|
35
|
+
if thread_key in {".", ".."} or ".." in thread_key:
|
|
36
|
+
raise InvalidArgsError("Thread key contains invalid segments.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def thread_file_path(thread_key: str) -> Path:
|
|
40
|
+
_validate_thread_key(thread_key)
|
|
41
|
+
return threads_dir() / f"{thread_key}.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def lock_file_path(thread_key: str) -> Path:
|
|
45
|
+
_validate_thread_key(thread_key)
|
|
46
|
+
return threads_dir() / f"{thread_key}.lock"
|
codex2opencode/state.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .errors import StateError
|
|
7
|
+
from .models import ThreadState
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def save_thread_state(path: Path, state: ThreadState) -> None:
|
|
11
|
+
try:
|
|
12
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
temp_path = path.with_suffix(".tmp")
|
|
14
|
+
temp_path.write_text(
|
|
15
|
+
json.dumps(state.to_dict(), indent=2, sort_keys=True),
|
|
16
|
+
encoding="utf-8",
|
|
17
|
+
)
|
|
18
|
+
temp_path.replace(path)
|
|
19
|
+
except OSError as exc:
|
|
20
|
+
raise StateError(f"Failed to write state to {path}: {exc}") from exc
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_thread_state(path: Path) -> ThreadState:
|
|
24
|
+
try:
|
|
25
|
+
raw = path.read_text(encoding="utf-8")
|
|
26
|
+
payload = json.loads(raw)
|
|
27
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
28
|
+
raise StateError(f"Failed to read state from {path}.") from exc
|
|
29
|
+
if not isinstance(payload, dict):
|
|
30
|
+
raise StateError(f"Invalid state payload in {path}.")
|
|
31
|
+
return ThreadState.from_dict(payload)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_workspace_root(workspace_root: str) -> str:
|
|
9
|
+
return str(Path(workspace_root).expanduser().resolve())
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make_thread_key(workspace_root: str, thread_name: str | None) -> str:
|
|
13
|
+
payload = {
|
|
14
|
+
"workspace_root": resolve_workspace_root(workspace_root),
|
|
15
|
+
"thread_name": thread_name,
|
|
16
|
+
}
|
|
17
|
+
encoded = json.dumps(payload, sort_keys=True).encode("utf-8")
|
|
18
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codex2opencode
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local Codex-to-Opencode bridge CLI
|
|
5
|
+
Author: OpenAI Codex
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# codex2opencode
|
|
13
|
+
|
|
14
|
+
`codex2opencode` is a local one-way bridge from Codex to Opencode.
|
|
15
|
+
|
|
16
|
+
It provides:
|
|
17
|
+
|
|
18
|
+
- a reusable Python CLI bridge
|
|
19
|
+
- Opencode session persistence via native `sessionID`
|
|
20
|
+
- deterministic per-thread locking
|
|
21
|
+
- export-backed session verification
|
|
22
|
+
- a thin Codex skill wrapper surface
|
|
23
|
+
- no non-stdlib Python runtime dependency inside the bridge
|
|
24
|
+
|
|
25
|
+
Current implementation target:
|
|
26
|
+
|
|
27
|
+
- macOS / POSIX environments with Python 3 and local Opencode CLI access
|
|
28
|
+
- not designed for Windows in its current `fcntl`-based form
|
|
29
|
+
|
|
30
|
+
## Current Status
|
|
31
|
+
|
|
32
|
+
Implemented and locally verified in this repository:
|
|
33
|
+
|
|
34
|
+
- `ask`
|
|
35
|
+
- `status`
|
|
36
|
+
- `forget`
|
|
37
|
+
- `gc`
|
|
38
|
+
- `doctor`
|
|
39
|
+
- automatic resume via stored Opencode `sessionID`
|
|
40
|
+
- Opencode-native `--fork`
|
|
41
|
+
- Opencode-native `--title`
|
|
42
|
+
- same-thread lock conflict handling
|
|
43
|
+
|
|
44
|
+
Primary verification commands:
|
|
45
|
+
|
|
46
|
+
- `PYTHONPATH=bridge python3 -m unittest discover -s tests -p 'test_*.py' -v`
|
|
47
|
+
- direct `codex2opencode ask ...` smoke
|
|
48
|
+
- direct `codex2opencode doctor ...` smoke
|
|
49
|
+
- opt-in real Opencode smoke when local auth is available
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python3 -m venv .venv
|
|
55
|
+
source .venv/bin/activate
|
|
56
|
+
python3 -m pip install -e .
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
When a PyPI release is available:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python3 -m pip install codex2opencode
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
You can also run it directly without installing:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
PYTHONPATH=bridge python3 -m codex2opencode ask --prompt "Reply with ok only" --workspace "$PWD"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
After editable install, both of these work:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python -m codex2opencode --help
|
|
75
|
+
codex2opencode --help
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Usage
|
|
79
|
+
|
|
80
|
+
Ask Opencode in the current workspace thread:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
codex2opencode ask --prompt "Review this design" --workspace "$PWD"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Successful `ask` output begins with a one-line metadata header before the reply body, for example:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
[oc provider=opencode model=mimo-v2-omni-free session=ses_...]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If session verification fails after a successful run, the bridge still prints the reply plus a minimal header with the session id, then exits non-zero.
|
|
93
|
+
|
|
94
|
+
Use a named thread:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
codex2opencode ask --prompt "Continue the review" --workspace "$PWD" --thread review
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Force a fresh session:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
codex2opencode ask --prompt "Start over" --workspace "$PWD" --new
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Fork the current mapped Opencode session:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
codex2opencode ask --prompt "Take this in a different direction" --workspace "$PWD" --fork
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Set an Opencode title on creation or fork:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
codex2opencode ask --prompt "Review the release checklist" --workspace "$PWD" --new --title "release-review"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Inspect stored thread state:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
codex2opencode status --workspace "$PWD"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Run diagnostics:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
codex2opencode doctor --workspace "$PWD"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Forget the current thread and delete the mapped Opencode session:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
codex2opencode forget --workspace "$PWD"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Remove stale bridge-owned files:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
codex2opencode gc --max-age-days 7
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Threading Model
|
|
143
|
+
|
|
144
|
+
By default, one workspace maps to one Opencode thread.
|
|
145
|
+
|
|
146
|
+
Use `--thread <name>` to split conversations inside the same repo:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
codex2opencode ask --prompt "Review API design" --workspace "$PWD" --thread api
|
|
150
|
+
codex2opencode ask --prompt "Review docs tone" --workspace "$PWD" --thread docs
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Use `--new` when you want a fresh Opencode session for the selected thread key.
|
|
154
|
+
|
|
155
|
+
Use `--fork` when you want Opencode to branch from the current mapped session instead of starting unrelated context. `--new` and `--fork` are intentionally mutually exclusive.
|
|
156
|
+
|
|
157
|
+
## Codex Skill
|
|
158
|
+
|
|
159
|
+
The Codex-facing wrapper lives at:
|
|
160
|
+
|
|
161
|
+
```text
|
|
162
|
+
skills/codex-to-opencode/SKILL.md
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The skill should stay thin. It should only:
|
|
166
|
+
|
|
167
|
+
- collect the user prompt
|
|
168
|
+
- choose default thread, named thread, `--new`, or `--fork`
|
|
169
|
+
- invoke `codex2opencode`
|
|
170
|
+
- return stdout or surface stderr
|
|
171
|
+
|
|
172
|
+
It must not own JSONL parsing, session storage, retries, export verification, or custom lock handling.
|
|
173
|
+
|
|
174
|
+
Recommended high-signal trigger phrases:
|
|
175
|
+
|
|
176
|
+
- `Use the codex-to-opencode skill`
|
|
177
|
+
- `ask Opencode about this`
|
|
178
|
+
- `ask oc about this`
|
|
179
|
+
- `send this to Opencode`
|
|
180
|
+
- `send this to oc`
|
|
181
|
+
- `let Opencode review this`
|
|
182
|
+
- `let oc review this`
|
|
183
|
+
- `give this to Opencode`
|
|
184
|
+
- `给 Opencode 看看`
|
|
185
|
+
- `给oc看看`
|
|
186
|
+
- `让 Opencode review 一下`
|
|
187
|
+
- `让oc review一下`
|
|
188
|
+
- `问问 Opencode`
|
|
189
|
+
- `问问oc`
|
|
190
|
+
|
|
191
|
+
Treat `oc` as a supported short alias for `Opencode` when it appears with a clear action.
|
|
192
|
+
Avoid relying on bare `opencode` by itself.
|
|
193
|
+
Avoid relying on bare `oc` by itself.
|
|
194
|
+
Trigger phrases should include a clear action like ask, review, check, continue, send, or look.
|
|
195
|
+
|
|
196
|
+
Practical everyday examples:
|
|
197
|
+
|
|
198
|
+
- `问问oc这个方案哪里最危险`
|
|
199
|
+
- `给oc看看这个 diff`
|
|
200
|
+
- `让oc review一下发布流程`
|
|
201
|
+
- `发给oc继续聊这个线程`
|
|
202
|
+
- `ask oc to review this design`
|
|
203
|
+
|
|
204
|
+
## Activation Scope
|
|
205
|
+
|
|
206
|
+
These paths are expected to work:
|
|
207
|
+
|
|
208
|
+
- new Codex sessions started after the skill was installed
|
|
209
|
+
- `codex exec` runs started after the skill was installed
|
|
210
|
+
- direct `codex2opencode` CLI usage
|
|
211
|
+
|
|
212
|
+
Do not assume already-open Codex sessions will hot-reload newly installed or updated skills.
|
|
213
|
+
|
|
214
|
+
If you changed trigger phrases or installed the skill during an existing session, restart Codex or open a fresh session before testing.
|
|
215
|
+
|
|
216
|
+
## Troubleshooting
|
|
217
|
+
|
|
218
|
+
If a trigger phrase does not route to Opencode:
|
|
219
|
+
|
|
220
|
+
1. Start a new Codex session.
|
|
221
|
+
2. Test with a high-signal phrase such as `Use the codex-to-opencode skill. Ask Opencode: reply with ok only.`
|
|
222
|
+
3. Verify the bridge directly with `codex2opencode ask --prompt "Reply with ok only" --workspace "$PWD"`.
|
|
223
|
+
4. Run `codex2opencode doctor --workspace "$PWD"` to confirm Opencode availability, debug-path output, and thread-state diagnostics.
|
|
224
|
+
|
|
225
|
+
If direct CLI usage works but a natural-language trigger does not, the issue is skill discovery in that session, not the bridge itself.
|
|
226
|
+
|
|
227
|
+
If `doctor` reports an `orphaned` thread state, the local mapping points at an Opencode session that no longer exports successfully. `forget` will remove that mapping and attempt remote deletion again.
|
|
228
|
+
|
|
229
|
+
## Environment
|
|
230
|
+
|
|
231
|
+
- `CODEX2OPENCODE_OPENCODE_BIN`: override the Opencode executable path
|
|
232
|
+
- `CODEX2OPENCODE_HOME`: override the bridge state root without changing your real shell `HOME`
|
|
233
|
+
- `CODEX2OPENCODE_RUN_REAL=1`: enable opt-in real Opencode integration tests
|
|
234
|
+
|
|
235
|
+
## Doctor Output
|
|
236
|
+
|
|
237
|
+
`codex2opencode doctor --workspace "$PWD"` prints JSON and checks:
|
|
238
|
+
|
|
239
|
+
- the resolved bridge root and key paths
|
|
240
|
+
- whether `opencode --version` is readable
|
|
241
|
+
- whether `opencode debug paths` is readable
|
|
242
|
+
- whether `opencode debug config` is parseable
|
|
243
|
+
- whether the selected thread state is `missing`, `ok`, `error`, or `orphaned`
|
|
244
|
+
- whether the mapped session can still be verified through `opencode export`
|
|
245
|
+
- whether the selected lock path is healthy or currently locked
|
|
246
|
+
|
|
247
|
+
It returns `0` when required checks are healthy and non-zero when Opencode availability checks fail, the state file is corrupted, or the mapped session is orphaned.
|
|
248
|
+
|
|
249
|
+
## State Layout
|
|
250
|
+
|
|
251
|
+
Default bridge state root:
|
|
252
|
+
|
|
253
|
+
```text
|
|
254
|
+
~/.codex/codex2opencode/
|
|
255
|
+
threads/
|
|
256
|
+
runs/
|
|
257
|
+
logs/
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Key files:
|
|
261
|
+
|
|
262
|
+
- `threads/<thread_key>.json`: current thread state
|
|
263
|
+
- `threads/<thread_key>.lock`: per-thread lock file
|
|
264
|
+
- `runs/<thread_key>/*.json`: per-run artifacts
|
|
265
|
+
- `logs/bridge.log`: append-only JSONL bridge events
|
|
266
|
+
|
|
267
|
+
## Boundaries
|
|
268
|
+
|
|
269
|
+
This repository is intentionally narrow:
|
|
270
|
+
|
|
271
|
+
- one-way Codex-to-Opencode only
|
|
272
|
+
- no Claude orchestration here
|
|
273
|
+
- no roundtable or multi-model discussion system
|
|
274
|
+
- no bridge-owned retry orchestration
|
|
275
|
+
- no over-generalized multi-backend framework
|
|
276
|
+
|
|
277
|
+
For design rationale and internals, see [ARCHITECTURE.md](./ARCHITECTURE.md).
|
|
278
|
+
For developer workflow, see [DEVELOPMENT.md](./DEVELOPMENT.md).
|
|
279
|
+
For contribution and release details, see [CONTRIBUTING.md](./CONTRIBUTING.md).
|