ferry-codex 0.1.1__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.
- ferry_codex/__init__.py +5 -0
- ferry_codex/build_backend.py +121 -0
- ferry_codex/build_identity.py +8 -0
- ferry_codex/cli.py +34 -0
- ferry_codex/integration.py +445 -0
- ferry_codex-0.1.1.data/data/ferry_codex_resources/.agents/plugins/marketplace.json +20 -0
- ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/.codex-plugin/plugin.json +19 -0
- ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/.mcp.json +10 -0
- ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/bin/ferry-mcp.py +10 -0
- ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/requirements.lock +32 -0
- ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/skills/ferry/SKILL.md +58 -0
- ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/skills/ferry/references/worker-brief.md +54 -0
- ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/src/ferry_mcp/__init__.py +3 -0
- ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/src/ferry_mcp/adapter.py +392 -0
- ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/src/ferry_mcp/advisory.py +165 -0
- ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/src/ferry_mcp/server.py +121 -0
- ferry_codex-0.1.1.dist-info/METADATA +162 -0
- ferry_codex-0.1.1.dist-info/RECORD +26 -0
- ferry_codex-0.1.1.dist-info/WHEEL +5 -0
- ferry_codex-0.1.1.dist-info/entry_points.txt +2 -0
- ferry_codex-0.1.1.dist-info/licenses/LICENSE +21 -0
- ferry_codex-0.1.1.dist-info/top_level.txt +2 -0
- ferry_mcp/__init__.py +3 -0
- ferry_mcp/adapter.py +392 -0
- ferry_mcp/advisory.py +165 -0
- ferry_mcp/server.py +121 -0
ferry_codex/__init__.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Release-only build identity guard around the standards-based setuptools backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterator
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
IDENTITY = ROOT / "ferry_codex" / "build_identity.py"
|
|
15
|
+
EGG_INFO = ROOT / "ferry_codex.egg-info"
|
|
16
|
+
BYTECODE = ROOT / "ferry_codex" / "__pycache__"
|
|
17
|
+
EGG_INFO_FILES = {"PKG-INFO", "SOURCES.txt", "dependency_links.txt", "entry_points.txt", "requires.txt", "top_level.txt"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _clear_backend_byproducts() -> None:
|
|
21
|
+
"""Remove only the exact, regular setuptools/Python byproducts this backend creates."""
|
|
22
|
+
if EGG_INFO.exists() or EGG_INFO.is_symlink():
|
|
23
|
+
if EGG_INFO.is_symlink() or not EGG_INFO.is_dir():
|
|
24
|
+
raise RuntimeError(f"refusing unexpected generated path: {EGG_INFO}")
|
|
25
|
+
entries = list(EGG_INFO.rglob("*"))
|
|
26
|
+
files = {item.relative_to(EGG_INFO).as_posix() for item in entries if item.is_file()}
|
|
27
|
+
if any(item.is_symlink() or item.is_dir() or not item.is_file() for item in entries) or files - EGG_INFO_FILES:
|
|
28
|
+
raise RuntimeError(f"refusing unexpected setuptools byproduct contents: {EGG_INFO}")
|
|
29
|
+
shutil.rmtree(EGG_INFO)
|
|
30
|
+
if BYTECODE.exists() or BYTECODE.is_symlink():
|
|
31
|
+
if BYTECODE.is_symlink() or not BYTECODE.is_dir():
|
|
32
|
+
raise RuntimeError(f"refusing unexpected generated path: {BYTECODE}")
|
|
33
|
+
modules = {item.stem for item in (ROOT / "ferry_codex").glob("*.py")}
|
|
34
|
+
for item in BYTECODE.rglob("*"):
|
|
35
|
+
if item.is_symlink() or item.is_dir() or not item.is_file():
|
|
36
|
+
raise RuntimeError(f"refusing unexpected Python bytecode byproduct: {BYTECODE}")
|
|
37
|
+
match = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*)\.cpython-\d+(?:\.opt-\d+)?\.pyc", item.name)
|
|
38
|
+
if match is None or match.group(1) not in modules:
|
|
39
|
+
raise RuntimeError(f"refusing unexpected Python bytecode byproduct: {BYTECODE}")
|
|
40
|
+
shutil.rmtree(BYTECODE)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _run_hook(action):
|
|
44
|
+
"""Run one PEP 517 hook without allowing cleanup to erase its original cause."""
|
|
45
|
+
try:
|
|
46
|
+
result = action()
|
|
47
|
+
except BaseException as primary:
|
|
48
|
+
try:
|
|
49
|
+
_clear_backend_byproducts()
|
|
50
|
+
except BaseException as cleanup:
|
|
51
|
+
raise RuntimeError(f"PEP 517 hook failed: {primary}; cleanup failed: {cleanup}") from primary
|
|
52
|
+
raise
|
|
53
|
+
_clear_backend_byproducts()
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _release_commit() -> str:
|
|
58
|
+
_clear_backend_byproducts()
|
|
59
|
+
requested = os.environ.get("FERRY_BUILD_COMMIT")
|
|
60
|
+
if not requested or not re.fullmatch(r"[0-9a-f]{40}", requested):
|
|
61
|
+
raise RuntimeError("set FERRY_BUILD_COMMIT to the exact lowercase 40-character release commit")
|
|
62
|
+
observed = subprocess.run(("git", "-C", str(ROOT), "rev-parse", "HEAD"), check=True, text=True, stdout=subprocess.PIPE).stdout.strip()
|
|
63
|
+
dirty = subprocess.run(("git", "-C", str(ROOT), "status", "--porcelain"), check=True, text=True, stdout=subprocess.PIPE).stdout
|
|
64
|
+
if observed != requested or dirty:
|
|
65
|
+
raise RuntimeError("release artifacts require a clean checkout at FERRY_BUILD_COMMIT")
|
|
66
|
+
return requested
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _source_sdist_commit() -> str | None:
|
|
70
|
+
"""Only an unpacked sdist may reuse its immutable embedded source commit."""
|
|
71
|
+
if (ROOT / ".git").exists() or not (ROOT / "PKG-INFO").is_file():
|
|
72
|
+
return None
|
|
73
|
+
match = re.search(r'SOURCE_COMMIT = "([0-9a-f]{40})"', IDENTITY.read_text(encoding="utf-8"))
|
|
74
|
+
return match.group(1) if match else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@contextmanager
|
|
78
|
+
def _embedded_identity() -> Iterator[None]:
|
|
79
|
+
commit = _release_commit()
|
|
80
|
+
original = IDENTITY.read_text(encoding="utf-8")
|
|
81
|
+
replacement = re.sub(r'SOURCE_COMMIT = ".*"', f'SOURCE_COMMIT = "{commit}"', original)
|
|
82
|
+
IDENTITY.write_text(replacement, encoding="utf-8")
|
|
83
|
+
try:
|
|
84
|
+
yield
|
|
85
|
+
finally:
|
|
86
|
+
IDENTITY.write_text(original, encoding="utf-8")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_wheel(wheel_directory: str, config_settings=None, metadata_directory=None) -> str:
|
|
90
|
+
from setuptools import build_meta as backend
|
|
91
|
+
# A wheel directly from a checkout must bind to that clean checkout. A wheel
|
|
92
|
+
# from an sdist instead reuses the immutable identity already embedded there.
|
|
93
|
+
def action():
|
|
94
|
+
if _source_sdist_commit() is not None:
|
|
95
|
+
return backend.build_wheel(wheel_directory, config_settings, metadata_directory)
|
|
96
|
+
with _embedded_identity():
|
|
97
|
+
return backend.build_wheel(wheel_directory, config_settings, metadata_directory)
|
|
98
|
+
return _run_hook(action)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_sdist(sdist_directory: str, config_settings=None) -> str:
|
|
102
|
+
from setuptools import build_meta as backend
|
|
103
|
+
def action():
|
|
104
|
+
with _embedded_identity():
|
|
105
|
+
return backend.build_sdist(sdist_directory, config_settings)
|
|
106
|
+
return _run_hook(action)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def prepare_metadata_for_build_wheel(metadata_directory: str, config_settings=None) -> str:
|
|
110
|
+
from setuptools import build_meta as backend
|
|
111
|
+
return _run_hook(lambda: backend.prepare_metadata_for_build_wheel(metadata_directory, config_settings))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_requires_for_build_wheel(config_settings=None):
|
|
115
|
+
from setuptools import build_meta as backend
|
|
116
|
+
return _run_hook(lambda: backend.get_requires_for_build_wheel(config_settings))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_requires_for_build_sdist(config_settings=None):
|
|
120
|
+
from setuptools import build_meta as backend
|
|
121
|
+
return _run_hook(lambda: backend.get_requires_for_build_sdist(config_settings))
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Build identity embedded in source and replaced in release artifacts."""
|
|
2
|
+
|
|
3
|
+
PUBLIC_VERSION = "0.1.1"
|
|
4
|
+
# A release build replaces this with the exact clean source commit. Keeping the
|
|
5
|
+
# source marker explicit prevents an unpacked checkout from masquerading as an
|
|
6
|
+
# immutable distribution.
|
|
7
|
+
SOURCE_COMMIT = "3ca3f13b6ef4bb758af20421f9274a5fc4a27199"
|
|
8
|
+
FULL_VERSION = f"{PUBLIC_VERSION}+{SOURCE_COMMIT[:12]}"
|
ferry_codex/cli.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""The intentionally small public Ferry console seam."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .build_identity import FULL_VERSION
|
|
10
|
+
from .integration import IntegrationError, setup, status, uninstall
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
parser = argparse.ArgumentParser(prog="ferry", description="Reconcile Ferry's installed Codex plugin integration.")
|
|
15
|
+
parser.add_argument("--version", action="version", version=FULL_VERSION)
|
|
16
|
+
parser.add_argument("--ferry-home", type=Path, help=argparse.SUPPRESS)
|
|
17
|
+
parser.add_argument("--codex", help=argparse.SUPPRESS)
|
|
18
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
for name in ("setup", "status", "uninstall"):
|
|
20
|
+
subcommands.add_parser(name)
|
|
21
|
+
args = parser.parse_args(argv)
|
|
22
|
+
try:
|
|
23
|
+
{"setup": setup, "status": status, "uninstall": uninstall}[args.command](ferry_home=args.ferry_home, codex=args.codex)
|
|
24
|
+
except IntegrationError as exc:
|
|
25
|
+
print(f"ferry: {exc}", file=sys.stderr)
|
|
26
|
+
return 1
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
print(f"ferry: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
29
|
+
return 1
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Installed-console integration; Codex remains the plugin lifecycle owner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import uuid
|
|
14
|
+
from importlib import metadata
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .build_identity import FULL_VERSION, PUBLIC_VERSION
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SDK_PIN = "0.147.0"
|
|
22
|
+
SDK_NAME = "openai-codex"
|
|
23
|
+
SDK_CLI_REQUIREMENT = "openai-codex-cli-bin"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IntegrationError(RuntimeError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run(*args: str) -> None:
|
|
31
|
+
subprocess.run(args, check=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_json(*args: str) -> Any:
|
|
35
|
+
try:
|
|
36
|
+
completed = subprocess.run(args, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
37
|
+
except subprocess.CalledProcessError as exc:
|
|
38
|
+
stderr = (exc.stderr or "").strip()
|
|
39
|
+
detail = f": {stderr}" if stderr else ""
|
|
40
|
+
raise IntegrationError(f"Codex command failed ({exc.returncode}) for {' '.join(args[1:])}{detail}") from exc
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(completed.stdout)
|
|
43
|
+
except json.JSONDecodeError as exc:
|
|
44
|
+
raise IntegrationError(f"Codex returned invalid JSON for {' '.join(args[1:])}: {exc}") from exc
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def plugin_digest(plugin: Path) -> str:
|
|
48
|
+
digest = hashlib.sha256()
|
|
49
|
+
for path in sorted(item for item in plugin.rglob("*") if item.is_file()):
|
|
50
|
+
relative = path.relative_to(plugin).as_posix()
|
|
51
|
+
data = path.read_bytes()
|
|
52
|
+
if relative == ".codex-plugin/plugin.json":
|
|
53
|
+
manifest = json.loads(data)
|
|
54
|
+
manifest["version"] = manifest["version"].split("+", 1)[0]
|
|
55
|
+
data = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode()
|
|
56
|
+
digest.update(relative.encode() + b"\0" + data + b"\0")
|
|
57
|
+
return digest.hexdigest()[:12]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _safe_parent(destination: Path, root: Path) -> Path:
|
|
61
|
+
root = root.resolve()
|
|
62
|
+
if not destination.is_relative_to(root):
|
|
63
|
+
raise IntegrationError("staged destination escaped install root")
|
|
64
|
+
current = root
|
|
65
|
+
for part in destination.relative_to(root).parts:
|
|
66
|
+
current = current / part
|
|
67
|
+
if current.is_symlink():
|
|
68
|
+
raise IntegrationError(f"refusing symlinked staged destination: {current}")
|
|
69
|
+
if current.exists() and not current.is_dir() and current != destination:
|
|
70
|
+
raise IntegrationError(f"staged parent is not a directory: {current}")
|
|
71
|
+
parent = destination.parent
|
|
72
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
if not parent.resolve().is_relative_to(root):
|
|
74
|
+
raise IntegrationError("staged parent escaped install root")
|
|
75
|
+
return parent
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _safe_stage(source: Path, destination: Path, root: Path) -> Path:
|
|
79
|
+
parent = _safe_parent(destination, root)
|
|
80
|
+
candidate = Path(tempfile.mkdtemp(prefix=".ferry-stage-", dir=parent))
|
|
81
|
+
try:
|
|
82
|
+
shutil.copytree(source, candidate, dirs_exist_ok=True, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
|
83
|
+
return candidate
|
|
84
|
+
except BaseException:
|
|
85
|
+
shutil.rmtree(candidate)
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _stage_file(source: Path, destination: Path, root: Path, replace=os.replace) -> None:
|
|
90
|
+
parent = _safe_parent(destination, root)
|
|
91
|
+
fd, raw_candidate = tempfile.mkstemp(prefix=".ferry-marketplace-", dir=parent)
|
|
92
|
+
os.close(fd)
|
|
93
|
+
candidate = Path(raw_candidate)
|
|
94
|
+
try:
|
|
95
|
+
shutil.copyfile(source, candidate)
|
|
96
|
+
replace(candidate, destination)
|
|
97
|
+
except BaseException:
|
|
98
|
+
if candidate.exists():
|
|
99
|
+
candidate.unlink()
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _replace_stage(candidate: Path, destination: Path, replace=os.replace) -> None:
|
|
104
|
+
parent = destination.parent
|
|
105
|
+
backup = parent / f".ferry-backup-{uuid.uuid4().hex}"
|
|
106
|
+
had_old = destination.exists()
|
|
107
|
+
try:
|
|
108
|
+
if had_old:
|
|
109
|
+
replace(destination, backup)
|
|
110
|
+
replace(candidate, destination)
|
|
111
|
+
except BaseException as primary:
|
|
112
|
+
rollback_error = None
|
|
113
|
+
if had_old and backup.exists() and not destination.exists():
|
|
114
|
+
try:
|
|
115
|
+
replace(backup, destination)
|
|
116
|
+
except BaseException as rollback:
|
|
117
|
+
rollback_error = rollback
|
|
118
|
+
if candidate.exists():
|
|
119
|
+
shutil.rmtree(candidate)
|
|
120
|
+
if rollback_error is not None:
|
|
121
|
+
raise IntegrationError(f"stage replacement failed: {primary}; rollback failed: {rollback_error}; retained_backup={backup}") from primary
|
|
122
|
+
raise
|
|
123
|
+
finally:
|
|
124
|
+
if backup.exists() and destination.exists():
|
|
125
|
+
shutil.rmtree(backup)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def package_plugin_root() -> Path:
|
|
129
|
+
root = Path(sys.prefix) / "ferry_codex_resources" / "plugins" / "ferry"
|
|
130
|
+
required = (".codex-plugin/plugin.json", ".mcp.json", "skills/ferry/SKILL.md", "src/ferry_mcp/server.py")
|
|
131
|
+
if not root.is_dir() or any(not (root / item).is_file() for item in required):
|
|
132
|
+
raise IntegrationError(f"installed Ferry package resources are incomplete: {root}")
|
|
133
|
+
return root
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def marketplace_file() -> Path:
|
|
137
|
+
path = Path(sys.prefix) / "ferry_codex_resources" / ".agents" / "plugins" / "marketplace.json"
|
|
138
|
+
if not path.is_file():
|
|
139
|
+
raise IntegrationError(f"installed Ferry marketplace resource is missing: {path}")
|
|
140
|
+
return path
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _codex_path(value: str | None = None) -> Path:
|
|
144
|
+
raw = value or shutil.which("codex")
|
|
145
|
+
if raw is None:
|
|
146
|
+
raise IntegrationError("HOST_CODEX_UNAVAILABLE: host codex executable was not found on PATH")
|
|
147
|
+
path = Path(raw).absolute()
|
|
148
|
+
if not path.is_file():
|
|
149
|
+
raise IntegrationError("HOST_CODEX_UNAVAILABLE: host codex executable is not an existing absolute file")
|
|
150
|
+
return path
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _validate_sdk_runtime() -> None:
|
|
154
|
+
# Repository setup imports this module before it installs its venv. Keep the
|
|
155
|
+
# import path stdlib-only; packaging is an ordinary installed runtime dep.
|
|
156
|
+
from packaging.requirements import Requirement
|
|
157
|
+
try:
|
|
158
|
+
installed = metadata.version(SDK_NAME)
|
|
159
|
+
except metadata.PackageNotFoundError as exc:
|
|
160
|
+
raise IntegrationError(f"{SDK_NAME}=={SDK_PIN} is not installed; run ferry setup") from exc
|
|
161
|
+
if installed != SDK_PIN:
|
|
162
|
+
raise IntegrationError(f"{SDK_NAME} must be exactly {SDK_PIN}; found {installed}")
|
|
163
|
+
try:
|
|
164
|
+
bundled_cli = metadata.version(SDK_CLI_REQUIREMENT)
|
|
165
|
+
except metadata.PackageNotFoundError:
|
|
166
|
+
pass
|
|
167
|
+
else:
|
|
168
|
+
raise IntegrationError(f"{SDK_CLI_REQUIREMENT} must be absent; found {bundled_cli}")
|
|
169
|
+
failures: list[str] = []
|
|
170
|
+
for raw in metadata.requires(SDK_NAME) or []:
|
|
171
|
+
requirement = Requirement(raw)
|
|
172
|
+
if requirement.name.lower().replace("_", "-") == SDK_CLI_REQUIREMENT:
|
|
173
|
+
if str(requirement.specifier) != f"=={SDK_PIN}":
|
|
174
|
+
failures.append(f"unexpected SDK CLI metadata requirement: {raw}")
|
|
175
|
+
continue
|
|
176
|
+
if requirement.marker and not requirement.marker.evaluate():
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
actual = metadata.version(requirement.name)
|
|
180
|
+
except metadata.PackageNotFoundError:
|
|
181
|
+
failures.append(f"missing {raw}")
|
|
182
|
+
else:
|
|
183
|
+
if requirement.specifier and actual not in requirement.specifier:
|
|
184
|
+
failures.append(f"conflicting {raw}; found {actual}")
|
|
185
|
+
if failures:
|
|
186
|
+
raise IntegrationError("SDK runtime requirements are not satisfied: " + "; ".join(failures))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def install_sdk() -> None:
|
|
190
|
+
pin = f"{SDK_NAME}=={SDK_PIN}"
|
|
191
|
+
if importlib.util.find_spec("pip") is not None:
|
|
192
|
+
run(sys.executable, "-m", "pip", "install", "--no-deps", pin)
|
|
193
|
+
else:
|
|
194
|
+
metadata_path = Path(sys.prefix) / "pipx_metadata.json"
|
|
195
|
+
if metadata_path.is_symlink() or not metadata_path.is_file():
|
|
196
|
+
raise IntegrationError("pip is unavailable and this Ferry environment has no valid pipx metadata")
|
|
197
|
+
try:
|
|
198
|
+
pipx_metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
199
|
+
environment = pipx_metadata["environment"]
|
|
200
|
+
package = pipx_metadata["main_package"]["package"]
|
|
201
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
|
|
202
|
+
raise IntegrationError("pip is unavailable and pipx metadata is malformed") from exc
|
|
203
|
+
prefix = Path(sys.prefix)
|
|
204
|
+
if environment != "ferry-codex" or package != "ferry-codex" or prefix.name != environment or prefix.parent.name != "venvs":
|
|
205
|
+
raise IntegrationError("pipx metadata does not identify this environment as ferry-codex")
|
|
206
|
+
pipx = shutil.which("pipx")
|
|
207
|
+
if pipx is None or not Path(pipx).is_file():
|
|
208
|
+
raise IntegrationError("pip is unavailable and pipx executable was not found on PATH")
|
|
209
|
+
run(str(Path(pipx).absolute()), "runpip", environment, "install", "--no-deps", pin)
|
|
210
|
+
_validate_sdk_runtime()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _set_staged_binding(candidate: Path, codex: Path) -> None:
|
|
214
|
+
config_path = candidate / ".mcp.json"
|
|
215
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
216
|
+
server = config["mcpServers"]["ferry"]
|
|
217
|
+
server["command"] = str(Path(sys.executable))
|
|
218
|
+
server["args"] = ["./bin/ferry-mcp.py"]
|
|
219
|
+
server["cwd"] = "."
|
|
220
|
+
server["env"] = {**server.get("env", {}), "FERRY_CODEX_BIN": str(codex), "FERRY_BUILD_VERSION": FULL_VERSION}
|
|
221
|
+
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
222
|
+
manifest_path = candidate / ".codex-plugin" / "plugin.json"
|
|
223
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
224
|
+
if manifest["version"] != PUBLIC_VERSION:
|
|
225
|
+
raise IntegrationError("plugin public version does not match package metadata")
|
|
226
|
+
manifest["version"] = FULL_VERSION
|
|
227
|
+
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _records(payload: Any, keys: tuple[str, ...]) -> list[dict[str, Any]]:
|
|
231
|
+
if isinstance(payload, list):
|
|
232
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
233
|
+
if isinstance(payload, dict):
|
|
234
|
+
for key in keys:
|
|
235
|
+
value = payload.get(key)
|
|
236
|
+
if isinstance(value, list):
|
|
237
|
+
return [item for item in value if isinstance(item, dict)]
|
|
238
|
+
raise IntegrationError("Codex JSON response did not contain the expected record list")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _owned_marketplace(host: Path, root: Path) -> bool:
|
|
242
|
+
records = _records(run_json(str(host), "plugin", "marketplace", "list", "--json"), ("marketplaces", "items"))
|
|
243
|
+
matches = [record for record in records if record.get("name") == "ferry"]
|
|
244
|
+
if not matches:
|
|
245
|
+
return False
|
|
246
|
+
if len(matches) != 1:
|
|
247
|
+
raise IntegrationError("Codex reports multiple marketplaces named ferry")
|
|
248
|
+
observed = matches[0].get("root", matches[0].get("path"))
|
|
249
|
+
if not isinstance(observed, str):
|
|
250
|
+
raise IntegrationError("Codex ferry marketplace JSON omitted its root path")
|
|
251
|
+
if Path(observed).expanduser().resolve() != root.resolve():
|
|
252
|
+
raise IntegrationError(f"refusing foreign marketplace named ferry: {observed}")
|
|
253
|
+
return True
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _installed_plugin(host: Path) -> dict[str, Any] | None:
|
|
257
|
+
records = _records(run_json(str(host), "plugin", "list", "--marketplace", "ferry", "--json"), ("installed",))
|
|
258
|
+
matches = [record for record in records if record.get("pluginId") == "ferry@ferry" and record.get("marketplaceName") == "ferry"]
|
|
259
|
+
if len(matches) > 1:
|
|
260
|
+
raise IntegrationError("Codex reports multiple ferry@ferry plugins")
|
|
261
|
+
if not matches:
|
|
262
|
+
return None
|
|
263
|
+
record = matches[0]
|
|
264
|
+
if not isinstance(record.get("version"), str):
|
|
265
|
+
raise IntegrationError("Codex ferry@ferry JSON omitted its version")
|
|
266
|
+
return record
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _matching_plugin(host: Path) -> bool:
|
|
270
|
+
record = _installed_plugin(host)
|
|
271
|
+
if record is None:
|
|
272
|
+
return False
|
|
273
|
+
if record.get("enabled") is not True:
|
|
274
|
+
raise IntegrationError("ferry@ferry is not enabled")
|
|
275
|
+
if record.get("version") != FULL_VERSION:
|
|
276
|
+
raise IntegrationError(f"ferry@ferry version is stale: expected {FULL_VERSION}, found {record.get('version')!r}")
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _restore_prior(host: Path, stage_root: Path, snapshot: Path | None, prior_files: dict[Path, bytes] | None,
|
|
281
|
+
had_marketplace: bool, prior_plugin: dict[str, Any] | None) -> None:
|
|
282
|
+
failures: list[BaseException] = []
|
|
283
|
+
try:
|
|
284
|
+
if stage_root.exists():
|
|
285
|
+
shutil.rmtree(stage_root)
|
|
286
|
+
if snapshot is not None:
|
|
287
|
+
os.replace(snapshot, stage_root)
|
|
288
|
+
except BaseException as exc:
|
|
289
|
+
failures.append(exc)
|
|
290
|
+
try:
|
|
291
|
+
if _installed_plugin(host) is not None:
|
|
292
|
+
run(str(host), "plugin", "remove", "ferry@ferry", "--json")
|
|
293
|
+
if had_marketplace:
|
|
294
|
+
run(str(host), "plugin", "marketplace", "add", str(stage_root), "--json")
|
|
295
|
+
if prior_plugin is not None:
|
|
296
|
+
run(str(host), "plugin", "add", "ferry@ferry", "--json")
|
|
297
|
+
else:
|
|
298
|
+
if _owned_marketplace(host, stage_root):
|
|
299
|
+
run(str(host), "plugin", "marketplace", "remove", "ferry", "--json")
|
|
300
|
+
except BaseException as exc:
|
|
301
|
+
failures.append(exc)
|
|
302
|
+
try:
|
|
303
|
+
if _owned_marketplace(host, stage_root) != had_marketplace:
|
|
304
|
+
raise IntegrationError("rollback marketplace registration does not match its prior state")
|
|
305
|
+
restored_plugin = _installed_plugin(host)
|
|
306
|
+
if prior_plugin is None:
|
|
307
|
+
if restored_plugin is not None:
|
|
308
|
+
raise IntegrationError("rollback left ferry@ferry installed although it was previously absent")
|
|
309
|
+
elif restored_plugin is None or any(restored_plugin.get(key) != prior_plugin.get(key)
|
|
310
|
+
for key in ("pluginId", "marketplaceName", "enabled", "version")):
|
|
311
|
+
raise IntegrationError("rollback ferry@ferry registration does not match its prior state")
|
|
312
|
+
if prior_files is not None:
|
|
313
|
+
restored_files = {item.relative_to(stage_root): item.read_bytes()
|
|
314
|
+
for item in stage_root.rglob("*") if item.is_file()}
|
|
315
|
+
if restored_files != prior_files:
|
|
316
|
+
raise IntegrationError("rollback staged marketplace files do not match their prior state")
|
|
317
|
+
except BaseException as exc:
|
|
318
|
+
failures.append(exc)
|
|
319
|
+
if failures:
|
|
320
|
+
raise IntegrationError("; ".join(str(item) for item in failures)) from failures[0]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def setup(*, ferry_home: Path | None = None, codex: str | None = None) -> None:
|
|
324
|
+
if sys.version_info < (3, 10):
|
|
325
|
+
raise IntegrationError("Ferry requires Python 3.10 or later")
|
|
326
|
+
host = _codex_path(codex)
|
|
327
|
+
root = (ferry_home or Path.home() / ".ferry").expanduser().resolve()
|
|
328
|
+
stage_root = root / "marketplace"
|
|
329
|
+
# This observation is deliberately before dependency installation: a foreign
|
|
330
|
+
# ferry marketplace is a fail-closed ownership collision, never setup work.
|
|
331
|
+
had_marketplace = _owned_marketplace(host, stage_root)
|
|
332
|
+
if stage_root.exists() and not had_marketplace:
|
|
333
|
+
raise IntegrationError(f"refusing unregistered Ferry marketplace: {stage_root}")
|
|
334
|
+
prior_plugin = _installed_plugin(host) if had_marketplace else None
|
|
335
|
+
source = package_plugin_root()
|
|
336
|
+
marketplace = marketplace_file()
|
|
337
|
+
install_sdk()
|
|
338
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
339
|
+
staged_plugin = stage_root / "plugins" / "ferry"
|
|
340
|
+
staged_marketplace = stage_root / ".agents" / "plugins" / "marketplace.json"
|
|
341
|
+
snapshot: Path | None = None
|
|
342
|
+
snapshot_parent: Path | None = None
|
|
343
|
+
prior_files: dict[Path, bytes] | None = None
|
|
344
|
+
candidate: Path | None = None
|
|
345
|
+
mutation_started = False
|
|
346
|
+
try:
|
|
347
|
+
if stage_root.exists():
|
|
348
|
+
if stage_root.is_symlink():
|
|
349
|
+
raise IntegrationError(f"refusing symlinked Ferry marketplace: {stage_root}")
|
|
350
|
+
snapshot = Path(tempfile.mkdtemp(prefix=".ferry-rollback-", dir=root)) / "marketplace"
|
|
351
|
+
snapshot_parent = snapshot.parent
|
|
352
|
+
shutil.copytree(stage_root, snapshot)
|
|
353
|
+
prior_files = {item.relative_to(stage_root): item.read_bytes()
|
|
354
|
+
for item in stage_root.rglob("*") if item.is_file()}
|
|
355
|
+
candidate = _safe_stage(source, staged_plugin, root)
|
|
356
|
+
_set_staged_binding(candidate, host)
|
|
357
|
+
mutation_started = True
|
|
358
|
+
_stage_file(marketplace, staged_marketplace, root)
|
|
359
|
+
_replace_stage(candidate, staged_plugin)
|
|
360
|
+
run(str(host), "plugin", "marketplace", "add", str(stage_root), "--json")
|
|
361
|
+
run(str(host), "plugin", "add", "ferry@ferry", "--json")
|
|
362
|
+
if not _owned_marketplace(host, stage_root) or not _matching_plugin(host):
|
|
363
|
+
raise IntegrationError("Codex did not register exactly one current enabled ferry@ferry plugin")
|
|
364
|
+
except BaseException as primary:
|
|
365
|
+
if mutation_started:
|
|
366
|
+
try:
|
|
367
|
+
_restore_prior(host, stage_root, snapshot, prior_files, had_marketplace, prior_plugin)
|
|
368
|
+
except BaseException as rollback:
|
|
369
|
+
raise IntegrationError(f"setup failed: {primary}; rollback failed: {rollback}") from primary
|
|
370
|
+
raise
|
|
371
|
+
finally:
|
|
372
|
+
if candidate is not None and candidate.exists():
|
|
373
|
+
shutil.rmtree(candidate)
|
|
374
|
+
if snapshot_parent is not None and snapshot_parent.exists():
|
|
375
|
+
shutil.rmtree(snapshot_parent)
|
|
376
|
+
print(f"Ferry {FULL_VERSION} is registered. Start a fresh Codex session to discover its Skill and MCP tools.")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _validate_staged_runtime(stage_root: Path, host: Path) -> None:
|
|
380
|
+
plugin_root = stage_root / "plugins" / "ferry"
|
|
381
|
+
config_path = plugin_root / ".mcp.json"
|
|
382
|
+
entrypoint = plugin_root / "bin" / "ferry-mcp.py"
|
|
383
|
+
source_root = plugin_root / "src"
|
|
384
|
+
server = source_root / "ferry_mcp" / "server.py"
|
|
385
|
+
if not config_path.is_file() or not entrypoint.is_file() or not server.is_file():
|
|
386
|
+
raise IntegrationError(f"Ferry MCP runtime is incomplete at {plugin_root}")
|
|
387
|
+
try:
|
|
388
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
389
|
+
mcp = config["mcpServers"]["ferry"]
|
|
390
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
|
|
391
|
+
raise IntegrationError(f"Ferry MCP binding is unreadable at {config_path}") from exc
|
|
392
|
+
if (mcp.get("command") != str(Path(sys.executable)) or mcp.get("args") != ["./bin/ferry-mcp.py"]
|
|
393
|
+
or mcp.get("cwd") != "." or not isinstance(mcp.get("env"), dict)
|
|
394
|
+
or mcp["env"].get("FERRY_CODEX_BIN") != str(host)
|
|
395
|
+
or mcp["env"].get("FERRY_BUILD_VERSION") != FULL_VERSION):
|
|
396
|
+
raise IntegrationError(f"Ferry MCP binding does not match the current runtime at {config_path}")
|
|
397
|
+
probe = f"import sys; sys.path.insert(0, {str(source_root)!r}); import ferry_mcp.server"
|
|
398
|
+
try:
|
|
399
|
+
subprocess.run((sys.executable, "-c", probe), check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
400
|
+
except subprocess.CalledProcessError as exc:
|
|
401
|
+
stderr = (exc.stderr or "").strip()
|
|
402
|
+
detail = f": {stderr}" if stderr else ""
|
|
403
|
+
raise IntegrationError(f"Ferry MCP runtime import failed ({exc.returncode}) at {source_root}{detail}") from exc
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def status(*, ferry_home: Path | None = None, codex: str | None = None) -> None:
|
|
407
|
+
host = _codex_path(codex)
|
|
408
|
+
_validate_sdk_runtime()
|
|
409
|
+
root = (ferry_home or Path.home() / ".ferry").expanduser().resolve()
|
|
410
|
+
stage_root = root / "marketplace"
|
|
411
|
+
if not _owned_marketplace(host, stage_root):
|
|
412
|
+
raise IntegrationError(f"Ferry marketplace is absent at {stage_root}; run ferry setup")
|
|
413
|
+
if not _matching_plugin(host):
|
|
414
|
+
raise IntegrationError("ferry@ferry is absent; run ferry setup")
|
|
415
|
+
plugin = stage_root / "plugins" / "ferry" / ".codex-plugin" / "plugin.json"
|
|
416
|
+
if not plugin.is_file():
|
|
417
|
+
raise IntegrationError(f"Ferry integration is absent at {root / 'marketplace'}; run ferry setup")
|
|
418
|
+
manifest = json.loads(plugin.read_text(encoding="utf-8"))
|
|
419
|
+
if manifest.get("version") != FULL_VERSION:
|
|
420
|
+
raise IntegrationError(f"Ferry integration is stale or unreadable: expected {FULL_VERSION}, found {manifest.get('version')!r}")
|
|
421
|
+
_validate_staged_runtime(stage_root, host)
|
|
422
|
+
print(json.dumps({"version": FULL_VERSION, "python": sys.executable, "codex": str(host), "marketplace": str(stage_root), "plugin_version": manifest["version"], "current": True}, sort_keys=True))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def uninstall(*, ferry_home: Path | None = None, codex: str | None = None) -> None:
|
|
426
|
+
host = _codex_path(codex)
|
|
427
|
+
root = (ferry_home or Path.home() / ".ferry").expanduser().resolve()
|
|
428
|
+
marketplace = root / "marketplace"
|
|
429
|
+
if marketplace.exists() and marketplace.is_symlink():
|
|
430
|
+
raise IntegrationError(f"refusing symlinked Ferry marketplace: {marketplace}")
|
|
431
|
+
owned = _owned_marketplace(host, marketplace)
|
|
432
|
+
if marketplace.exists() and not owned:
|
|
433
|
+
raise IntegrationError(f"refusing unregistered Ferry marketplace: {marketplace}")
|
|
434
|
+
installed = _installed_plugin(host)
|
|
435
|
+
if installed is not None:
|
|
436
|
+
run(str(host), "plugin", "remove", "ferry@ferry", "--json")
|
|
437
|
+
if owned:
|
|
438
|
+
run(str(host), "plugin", "marketplace", "remove", "ferry", "--json")
|
|
439
|
+
if _owned_marketplace(host, marketplace):
|
|
440
|
+
raise IntegrationError("Codex did not remove the Ferry marketplace")
|
|
441
|
+
if _installed_plugin(host) is not None:
|
|
442
|
+
raise IntegrationError("Codex did not remove ferry@ferry")
|
|
443
|
+
if marketplace.exists():
|
|
444
|
+
shutil.rmtree(marketplace)
|
|
445
|
+
print("Ferry integration is removed. Close every Ferry-using Codex session, then run pipx uninstall ferry-codex.")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ferry",
|
|
3
|
+
"interface": {
|
|
4
|
+
"displayName": "Ferry"
|
|
5
|
+
},
|
|
6
|
+
"plugins": [
|
|
7
|
+
{
|
|
8
|
+
"name": "ferry",
|
|
9
|
+
"source": {
|
|
10
|
+
"source": "local",
|
|
11
|
+
"path": "./plugins/ferry"
|
|
12
|
+
},
|
|
13
|
+
"policy": {
|
|
14
|
+
"installation": "AVAILABLE",
|
|
15
|
+
"authentication": "ON_INSTALL"
|
|
16
|
+
},
|
|
17
|
+
"category": "Productivity"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ferry",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Delegate bounded Codex work while preserving owner judgment.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Ferry"
|
|
7
|
+
},
|
|
8
|
+
"skills": "./skills/",
|
|
9
|
+
"interface": {
|
|
10
|
+
"displayName": "Ferry",
|
|
11
|
+
"shortDescription": "Bounded native or alternate-provider Codex workers.",
|
|
12
|
+
"longDescription": "Ferry keeps mission ownership with Codex while exposing one thin SDK-backed MCP for explicit alternate providers.",
|
|
13
|
+
"developerName": "Ferry",
|
|
14
|
+
"category": "Productivity",
|
|
15
|
+
"capabilities": ["Interactive"],
|
|
16
|
+
"defaultPrompt": ["Use Ferry to delegate this bounded task."]
|
|
17
|
+
},
|
|
18
|
+
"mcpServers": "./.mcp.json"
|
|
19
|
+
}
|