"""Install the ``opc-collab`` skill + CLI shim for external agents. Most OpenOPC-spawned external agents run with a dedicated HOME-style directory (``$CODEX_HOME``, ``$OPENCODE_CONFIG_DIR``) under ``/agent_homes//``. Claude Code keeps the user's normal config directory so it can reuse the already authenticated CLI login. Before each launch the broker calls the functions in this module to: * symlink the packaged ``SKILL.md`` into ``/skills/opc-collab/`` so the agent's native skill discovery finds the instructions; * drop an executable ``opc-collab`` shim into ``/bin/`` and prepend that directory to ``PATH`` so the agent can call the CLI from the shell. Everything is idempotent — repeated calls reconcile symlink targets and overwrite the shim only when its content would otherwise drift. """ from __future__ import annotations import os import stat import sys from pathlib import Path from loguru import logger from opc.core.config import get_opc_home SKILL_NAME = "opc-collab" _SKILL_SOURCE = Path(__file__).resolve().parent.parent / "skills_assets" / "opc_collab" _SKILL_FILES: tuple[str, ...] = ("SKILL.md",) def opc_bin_dir(opc_home: Path | None = None) -> Path: """Return the shared ``/bin/`` directory (created on demand). Kept outside each agent home so a single shim serves every agent. """ base = Path(opc_home) if opc_home else get_opc_home() bin_dir = base / "bin" bin_dir.mkdir(parents=True, exist_ok=True) return bin_dir def agent_home_dir(agent_slug: str, opc_home: Path | None = None) -> Path: """Return ``/agent_homes//`` (created on demand). ``agent_slug`` is a short identifier like ``codex`` / ``claude`` / ``opencode`` — one directory per external agent so each has its own native config space without colliding or polluting the user's ``~/.codex`` / ``~/.claude`` / ``~/.config/opencode``. """ base = Path(opc_home) if opc_home else get_opc_home() home = base / "agent_homes" / agent_slug home.mkdir(parents=True, exist_ok=True) return home def _write_opc_collab_shim(shim_path: Path) -> None: """Write (or refresh) the ``opc-collab`` executable shim. The shim pins ``sys.executable`` at install time so spawned agents use the same Python that's running OpenOPC, regardless of what Python they find on their own ``PATH``. """ python = sys.executable or "python3" content = ( "#!/bin/sh\n" "# Auto-generated by OpenOPC. Dispatches to `opc.cli_collab`.\n" "# Do not edit by hand; the skill installer rewrites this file.\n" f'exec "{python}" -m opc.cli_collab "$@"\n' ) try: existing = shim_path.read_text() except FileNotFoundError: existing = "" if existing != content: shim_path.write_text(content) mode = shim_path.stat().st_mode executable = mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH if mode != executable: shim_path.chmod(executable) def _write_opc_collab_cmd_shim(shim_path: Path) -> None: """Write the Windows ``opc-collab.cmd`` shim. Windows does not execute extensionless POSIX shell shims via normal ``CreateProcess`` / shell lookup. Keeping a ``.cmd`` sibling lets spawned agents call the collaboration CLI from PowerShell/CMD and via PATHEXT lookup. """ python = sys.executable or "python" content = ( "@echo off\r\n" "REM Auto-generated by OpenOPC. Dispatches to opc.cli_collab.\r\n" "REM Do not edit by hand; the skill installer rewrites this file.\r\n" f'"{python}" -m opc.cli_collab %*\r\n' ) try: existing = shim_path.read_text() except FileNotFoundError: existing = "" if existing != content: shim_path.write_text(content) def ensure_opc_collab_bin(opc_home: Path | None = None) -> Path: """Ensure ``/bin/opc-collab`` exists and is executable. Returns the bin directory (so callers can prepend it to ``PATH``).""" bin_dir = opc_bin_dir(opc_home) _write_opc_collab_shim(bin_dir / "opc-collab") if os.name == "nt": _write_opc_collab_cmd_shim(bin_dir / "opc-collab.cmd") return bin_dir def opc_collab_executable(bin_dir: Path) -> Path: """Return the platform-native collaboration CLI path.""" return Path(bin_dir) / ("opc-collab.cmd" if os.name == "nt" else "opc-collab") def _ensure_symlink(source: Path, target: Path) -> None: """Create or reconcile ``target`` → ``source`` as a symlink. Silently removes a stale target pointing elsewhere (this file is owned by the skill installer — the user has no business editing it). If symlinks are unsupported on this filesystem, falls back to copying the file content. The fallback is rare (Windows without developer mode). """ if not source.exists(): raise FileNotFoundError(f"skill source missing: {source}") target.parent.mkdir(parents=True, exist_ok=True) try: current = target.readlink() if target.is_symlink() else None except OSError: current = None if current is not None and Path(current).resolve() == source.resolve(): return if target.is_symlink() or target.exists(): target.unlink() try: target.symlink_to(source) except (OSError, NotImplementedError): target.write_bytes(source.read_bytes()) def install_opc_collab_skill(agent_home: Path) -> Path: """Install the ``opc-collab`` skill bundle into ``/skills/opc-collab/``. Returns the installed skill directory. Safe to call on every launch — the implementation reconciles existing symlinks instead of rewriting. """ skill_dir = Path(agent_home) / "skills" / SKILL_NAME skill_dir.mkdir(parents=True, exist_ok=True) for file_name in _SKILL_FILES: src = _SKILL_SOURCE / file_name if not src.exists(): logger.warning( f"install_opc_collab_skill: packaged asset missing: {src}; skipping" ) continue _ensure_symlink(src, skill_dir / file_name) return skill_dir def install_collab_surface( agent_slug: str, opc_home: Path | None = None, ) -> tuple[Path, Path]: """One-shot: ensure the agent home, the skill bundle, and the bin shim all exist. Returns ``(agent_home, bin_dir)`` so the caller can wire env vars. """ home = agent_home_dir(agent_slug, opc_home=opc_home) install_opc_collab_skill(home) bin_dir = ensure_opc_collab_bin(opc_home=opc_home) return home, bin_dir def prepend_to_path(existing_path: str, bin_dir: Path) -> str: """Return a ``PATH`` value with ``bin_dir`` at the front. Kept as a pure function so the broker can compose env maps without mutating ``os.environ``. ``bin_dir`` is idempotent — repeated calls do not duplicate the entry. """ bin_str = str(bin_dir) parts = [bin_str] for part in (existing_path or "").split(os.pathsep): if part and part != bin_str and part not in parts: parts.append(part) return os.pathsep.join(parts)