The owner sets, for every guest at once (users.json guestDefaults): - promptShortcuts: what a guest's composer starts with. A guest who never saved settings gets them as the seed of their ai-agent-settings ui-state. - sharedSkills: lab/project skills handed to every guest run. The scope carries them to the host sidecar, which symlinks them into a generated plugin and passes --plugin-dir: they show up as shared:<name>. GET/PUT /api/guest-defaults (owner), GET /api/guest-skills (the composer's mentions: shared + granted projects' skills), shareRef/sharedWithGuests on the skills catalog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
2.9 KiB
Python
79 lines
2.9 KiB
Python
"""
|
|
The owner's skills a guest run gets, as a throwaway Claude Code plugin.
|
|
|
|
A guest run starts in its project (``~/projects/<slug>``), so Claude Code only
|
|
loads that project's ``.claude/skills`` — the lab's own skills live in the
|
|
homelab repo and never reach it. The scope's ``skills`` list names the ones
|
|
the owner shared; :func:`plugin_dir` symlinks them into
|
|
``<cache>/<hash>/skills/`` next to a ``.claude-plugin/plugin.json`` named
|
|
``shared``, and the run is launched with ``--plugin-dir <that dir>``: the
|
|
skills show up as ``shared:<name>``.
|
|
|
|
One dir per distinct set (keyed by its hash), rebuilt in place when a link is
|
|
missing, so concurrent runs never see a half-written plugin.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
|
|
from config import SHARED_SKILLS_CACHE, SHARED_SKILLS_PROJECTS, SHARED_SKILLS_REPO
|
|
|
|
PLUGIN_NAME = "shared"
|
|
_PART = r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}"
|
|
_REF_RE = re.compile(rf"^(?:({_PART})/)?({_PART})$")
|
|
|
|
|
|
def resolve(ref: str) -> pathlib.Path | None:
|
|
"""A ref's skill dir — ``name`` in the repo, ``<project>/<name>`` in a
|
|
project — when it holds a ``SKILL.md``; None for anything else."""
|
|
m = _REF_RE.match(ref or "")
|
|
if not m or ".." in ref:
|
|
return None
|
|
project, name = m.groups()
|
|
root = SHARED_SKILLS_PROJECTS / project / ".claude" / "skills" if project \
|
|
else SHARED_SKILLS_REPO
|
|
d = root / name
|
|
return d if (d / "SKILL.md").is_file() else None
|
|
|
|
|
|
def plugin_dir(refs: list[str] | None) -> pathlib.Path | None:
|
|
"""The plugin dir carrying ``refs`` (None when none resolves). A later ref
|
|
whose name is already taken is skipped: the plugin is one flat namespace."""
|
|
found: dict[str, pathlib.Path] = {}
|
|
for ref in refs or []:
|
|
d = resolve(ref)
|
|
if d is not None and d.name not in found:
|
|
found[d.name] = d
|
|
if not found:
|
|
return None
|
|
key = hashlib.sha256(json.dumps(sorted((n, str(p)) for n, p in found.items()))
|
|
.encode()).hexdigest()[:16]
|
|
root = SHARED_SKILLS_CACHE / key
|
|
manifest = root / ".claude-plugin" / "plugin.json"
|
|
skills = root / "skills"
|
|
if manifest.is_file() and all((skills / n).exists() for n in found):
|
|
return root
|
|
skills.mkdir(parents=True, exist_ok=True)
|
|
manifest.parent.mkdir(parents=True, exist_ok=True)
|
|
for name, target in found.items():
|
|
link = skills / name
|
|
if link.is_symlink() or link.exists():
|
|
continue
|
|
tmp = skills / f".{name}.{os.getpid()}"
|
|
try:
|
|
os.symlink(target, tmp)
|
|
os.replace(tmp, link)
|
|
except OSError:
|
|
tmp.unlink(missing_ok=True)
|
|
tmp = manifest.with_suffix(f".{os.getpid()}.tmp")
|
|
tmp.write_text(json.dumps({
|
|
"name": PLUGIN_NAME, "version": "1.0.0",
|
|
"description": "Skills the ai-agent's owner shares with guests"}))
|
|
os.replace(tmp, manifest)
|
|
return root
|