Files
OpenOPC/opc/layer3_agent/skill_installer.py
T
2026-07-01 17:56:31 +08:00

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)