Prompt shortcuts are now shared across runners or scoped to one: `shared` +
`runner` ("local" = the lab, or a worker id). The composer resolves the
runner its prompt lands on (the pick, else the account's online worker, else
the lab) and offers the shared chips first, then that runner's; switching
runner seeds the newly revealed chips' default-on cascade. The resume box
uses the session's own runner. Settings → Prompt shortcuts becomes a Shared
section plus one tab per runner (Lab, each paired worker), with a Shared
checkbox per card and reordering kept within a section. Settings v3
migrates existing configs: worktree, commit, pr, notify, ask, clarify and
tasks become shared; everything else stays the lab's.
A worker run also lists that machine's skills instead of the lab catalog:
the sidecar's new GET /skills reports the default cwd's and the user-level
skills, the hub proxies it at /api/workers/{id}/skills (60 s cache, empty
when offline), and the composer swaps its mentions when the runner is a
worker (no lab agents there either).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
116 lines
4.1 KiB
Python
116 lines
4.1 KiB
Python
"""
|
|
The skills a run on *this* runner can use — what the hub's composer lists
|
|
under "Runs on: <worker>" instead of the lab's own catalog.
|
|
|
|
Claude Code loads skills from two places, and both are local to the machine
|
|
the session runs on: the user-level ``<config dir>/skills/`` (where the
|
|
worker installer links the ai-agent's ``notify`` skill) and the run's cwd
|
|
``.claude/skills/``. The lab's catalog (``backend/skills.py``) reads the hub's
|
|
workspace mount, which a worker doesn't have — so each runner reports its own
|
|
through ``GET /skills`` (bearer), and the hub proxies it per worker.
|
|
|
|
The frontmatter parser is a copy of the backend's ``parse_frontmatter`` (a
|
|
skill ``description`` routinely contains ``: ``, which PyYAML rejects), kept
|
|
here because the sidecar does not import the backend.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import pathlib
|
|
from typing import Callable
|
|
|
|
from fastapi import APIRouter, Header
|
|
|
|
SKILL_FILE = "SKILL.md"
|
|
|
|
|
|
def parse_frontmatter(text: str) -> dict:
|
|
"""Top-level ``key: value`` / ``key:`` + ``- item`` entries of a ``---`` block."""
|
|
if not text.startswith("---"):
|
|
return {}
|
|
end = text.find("\n---", 3)
|
|
if end == -1:
|
|
return {}
|
|
out: dict[str, str | list[str]] = {}
|
|
last_key: str | None = None
|
|
for line in text[3:end].split("\n"):
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
if stripped.startswith("- "):
|
|
if last_key is not None and out.get(last_key) in ("", None):
|
|
out[last_key] = []
|
|
if last_key is not None and isinstance(out.get(last_key), list):
|
|
out[last_key].append(stripped[2:].strip().strip("'\"")) # type: ignore[union-attr]
|
|
continue
|
|
if line.startswith((" ", "\t")):
|
|
continue
|
|
key, sep, val = line.partition(":")
|
|
if not sep:
|
|
continue
|
|
last_key = key.strip()
|
|
out[last_key] = val.strip().strip("'\"")
|
|
return out
|
|
|
|
|
|
def _as_list(v) -> list[str]:
|
|
if isinstance(v, list):
|
|
return [str(x).strip() for x in v if str(x).strip()]
|
|
if isinstance(v, str) and v.strip():
|
|
return [p.strip() for p in v.split(",") if p.strip()]
|
|
return []
|
|
|
|
|
|
def skill_roots(config_dir: pathlib.Path | None, cwd: pathlib.Path) -> list[tuple[str, pathlib.Path]]:
|
|
"""(source, dir) pairs in Claude Code's own precedence: the run's project
|
|
skills, then the user-level ones. ``config_dir`` None = the CLI's default
|
|
(``~/.claude``)."""
|
|
cfg = config_dir or pathlib.Path(os.path.expanduser("~/.claude"))
|
|
return [("project", cwd / ".claude" / "skills"), ("user", cfg / "skills")]
|
|
|
|
|
|
def list_skills(roots: list[tuple[str, pathlib.Path]]) -> list[dict]:
|
|
"""Every ``<root>/<name>/SKILL.md``, first root wins on a name clash."""
|
|
out: list[dict] = []
|
|
seen: set[str] = set()
|
|
for source, root in roots:
|
|
try:
|
|
entries = sorted(root.iterdir())
|
|
except OSError:
|
|
continue
|
|
for entry in entries:
|
|
if entry.name.startswith(".") or entry.name in seen:
|
|
continue
|
|
md = entry / SKILL_FILE
|
|
# follow_symlinks: the installer links a skill dir into ~/.claude/skills
|
|
if not md.is_file():
|
|
continue
|
|
try:
|
|
fm = parse_frontmatter(md.read_text(encoding="utf-8", errors="replace"))
|
|
except OSError:
|
|
fm = {}
|
|
seen.add(entry.name)
|
|
out.append({
|
|
"name": entry.name,
|
|
"title": fm.get("name") or entry.name,
|
|
"description": str(fm.get("description") or ""),
|
|
"triggerWords": _as_list(fm.get("trigger_words")),
|
|
"source": source,
|
|
"path": str(md),
|
|
})
|
|
return out
|
|
|
|
|
|
def make_router(auth: Callable[[str | None], None],
|
|
roots: Callable[[], list[tuple[str, pathlib.Path]]]) -> APIRouter:
|
|
r = APIRouter()
|
|
|
|
@r.get("/skills")
|
|
def skills(authorization: str | None = Header(default=None)) -> dict:
|
|
auth(authorization)
|
|
items = list_skills(roots())
|
|
return {"skills": items, "count": len(items)}
|
|
|
|
return r
|