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.
Files changed (26) hide show
  1. ferry_codex/__init__.py +5 -0
  2. ferry_codex/build_backend.py +121 -0
  3. ferry_codex/build_identity.py +8 -0
  4. ferry_codex/cli.py +34 -0
  5. ferry_codex/integration.py +445 -0
  6. ferry_codex-0.1.1.data/data/ferry_codex_resources/.agents/plugins/marketplace.json +20 -0
  7. ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/.codex-plugin/plugin.json +19 -0
  8. ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/.mcp.json +10 -0
  9. ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/bin/ferry-mcp.py +10 -0
  10. ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/requirements.lock +32 -0
  11. ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/skills/ferry/SKILL.md +58 -0
  12. ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/skills/ferry/references/worker-brief.md +54 -0
  13. ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/src/ferry_mcp/__init__.py +3 -0
  14. ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/src/ferry_mcp/adapter.py +392 -0
  15. ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/src/ferry_mcp/advisory.py +165 -0
  16. ferry_codex-0.1.1.data/data/ferry_codex_resources/plugins/ferry/src/ferry_mcp/server.py +121 -0
  17. ferry_codex-0.1.1.dist-info/METADATA +162 -0
  18. ferry_codex-0.1.1.dist-info/RECORD +26 -0
  19. ferry_codex-0.1.1.dist-info/WHEEL +5 -0
  20. ferry_codex-0.1.1.dist-info/entry_points.txt +2 -0
  21. ferry_codex-0.1.1.dist-info/licenses/LICENSE +21 -0
  22. ferry_codex-0.1.1.dist-info/top_level.txt +2 -0
  23. ferry_mcp/__init__.py +3 -0
  24. ferry_mcp/adapter.py +392 -0
  25. ferry_mcp/advisory.py +165 -0
  26. ferry_mcp/server.py +121 -0
@@ -0,0 +1,5 @@
1
+ """Ferry's installed-distribution integration surface."""
2
+
3
+ from .build_identity import FULL_VERSION, PUBLIC_VERSION
4
+
5
+ __all__ = ["FULL_VERSION", "PUBLIC_VERSION"]
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "mcpServers": {
3
+ "ferry": {
4
+ "command": "python3",
5
+ "args": ["./bin/ferry-mcp.py"],
6
+ "cwd": ".",
7
+ "required": true
8
+ }
9
+ }
10
+ }