Initial commit
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user