Files
ai-agent/sidecar/shared_skills.py
Gabriel Vidal a76cce0c71 feat(users): guest defaults — starting shortcuts + skills shared with guest runs
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>
2026-10-11 20:20:10 +02:00

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