195 lines
7.0 KiB
Python
195 lines
7.0 KiB
Python
"""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
|
|
``<opc_home>/agent_homes/<agent>/``. 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 ``<agent_home>/skills/opc-collab/``
|
|
so the agent's native skill discovery finds the instructions;
|
|
* drop an executable ``opc-collab`` shim into ``<opc_home>/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 ``<opc_home>/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 ``<opc_home>/agent_homes/<slug>/`` (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 ``<opc_home>/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 ``<agent_home>/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)
|