Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

1052 lines
39 KiB
Python

"""
SkillService
============
Capability-skill storage and runtime loading, nanobot-style.
A skill is a self-contained capability package the model consults *on
demand*: a ``SKILL.md`` playbook plus optional ``references/`` (and, once the
execution sandbox ships, ``scripts/``). Skills are never injected wholesale
into the system prompt — the prompt carries a one-line-per-skill manifest
(see :func:`render_skills_manifest`) and the model fetches full content
through the ``read_skill`` tool when a task matches. The exceptions are
skills marked ``always: true`` in frontmatter, whose bodies are eagerly
injected (used for house rules that must apply to every turn).
Two layers, user shadows builtin:
* builtin — shipped with the product under ``deeptutor/skills/builtin/``,
read-only at runtime;
* user — authored via the API under ``data/user/workspace/skills/``.
Each skill lives in its own directory::
<root>/<name>/SKILL.md
<root>/<name>/references/... (optional, readable via read_skill)
Frontmatter schema::
---
name: my-skill
description: One-line summary shown in the manifest.
tags: [style] # optional, user-facing organisation only
always: false # optional, eager-inject when true
requires: # optional availability gates
bins: [git] # CLI binaries that must exist on PATH
env: [GITHUB_TOKEN] # environment variables that must be set
sandbox: shell # needs the shell execution sandbox
---
A small ``.tags.json`` file next to the user skill directories holds the
canonical user-managed tag vocabulary so tags can be created, renamed, or
deleted independently of the skills that use them.
Behaviour/voice presets ("teacher", "peer", …) are NOT skills — they live in
:mod:`deeptutor.services.persona` and are eagerly injected because a persona
must shape the voice from the first token.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import json
import os
from pathlib import Path
import re
import shutil
from typing import Any
import yaml
from deeptutor.services.path_service import get_path_service
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
_TAG_RE = re.compile(r"^[a-z0-9][a-z0-9\- _]{0,31}$")
_DEFAULT_TAGS: tuple[str, ...] = ("style", "tool")
_TAGS_FILE = ".tags.json"
# Builtin skills shipped inside the package. Partners run on the chat agent
# loop, so this is the single builtin set for both product chat and partner
# workspaces (the old TutorBot-only skill set died with its engine).
BUILTIN_SKILLS_ROOT = Path(__file__).resolve().parents[2] / "skills" / "builtin"
# Hard cap for read_skill payloads so a huge reference file cannot flood the
# context window. Mirrors the truncation posture of the other read tools.
_MAX_READ_CHARS = 100_000
# Provenance ledger for skills imported from an external hub (ClawHub, …).
# Sits beside ``.tags.json`` so the UI/CLI can show "from clawhub@1.2.0" and
# an update path knows where each imported skill came from.
_HUB_LOCK_FILE = ".hub-lock.json"
# Bounds for importing a skill package from an untrusted source. A skill is a
# text playbook plus small supporting files — anything bigger is suspect, so
# the import fails closed instead of filling the workspace.
_IMPORT_MAX_FILE_BYTES = 1_000_000
_IMPORT_MAX_TOTAL_BYTES = 20_000_000
_IMPORT_MAX_FILES = 500
# Supporting-file suffixes an imported skill may carry (whitelist, lowercase).
# Text playbooks, references, and scripts only — never binaries or archives.
# Suffix-less files (LICENSE, Makefile) are allowed; copies drop the exec bit.
_IMPORT_ALLOWED_SUFFIXES = frozenset(
{
"",
".md", ".markdown", ".txt", ".rst",
".py", ".js", ".mjs", ".ts", ".sh", ".rb", ".lua", ".r",
".json", ".jsonl", ".yaml", ".yml", ".toml", ".ini", ".cfg",
".csv", ".tsv", ".html", ".htm", ".css", ".xml", ".sql",
".jinja", ".j2", ".tmpl", ".template",
}
) # fmt: skip
@dataclass(slots=True)
class SkillInfo:
name: str
description: str
tags: list[str] = field(default_factory=list)
source: str = "user" # "user" | "builtin" | "admin"
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"tags": list(self.tags),
"source": self.source,
"read_only": self.source != "user",
}
@dataclass(slots=True)
class SkillDetail:
name: str
description: str
content: str
tags: list[str] = field(default_factory=list)
source: str = "user"
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"content": self.content,
"tags": list(self.tags),
"source": self.source,
"read_only": self.source != "user",
}
@dataclass(slots=True)
class SkillSummaryEntry:
"""One manifest row: what the model sees about a skill before reading it."""
name: str
description: str
available: bool = True
missing: list[str] = field(default_factory=list)
always: bool = False
@dataclass(slots=True)
class SkillInstallResult:
"""Outcome of :meth:`SkillService.install_tree`.
``skipped`` lists support files the import gate dropped, as
``(relative-path, reason)`` pairs, so callers can surface what did not
make it into the workspace instead of failing the whole install.
"""
info: SkillInfo
skipped: list[tuple[str, str]] = field(default_factory=list)
class SkillNotFoundError(Exception):
pass
class SkillExistsError(Exception):
pass
class SkillReadOnlyError(Exception):
"""Raised when a write targets a builtin (read-only) skill."""
class InvalidSkillNameError(Exception):
pass
class InvalidSkillPathError(Exception):
"""Raised when ``read_skill_file`` is asked for a path outside the skill dir."""
class SkillImportError(Exception):
"""Raised when an imported skill package fails a structural/safety gate."""
class InvalidTagError(Exception):
pass
class TagNotFoundError(Exception):
pass
class TagExistsError(Exception):
pass
def _sandbox_available(kind: str) -> bool:
"""Whether the execution sandbox satisfies a ``requires.sandbox`` gate.
Late import so the skill layer stays decoupled from the sandbox layer;
fails closed when the sandbox service is absent or disabled.
"""
try:
from deeptutor.services.sandbox import exec_capability_available
return exec_capability_available(kind)
except Exception:
return False
class SkillService:
"""CRUD + runtime loading for SKILL.md packages (builtin + user layers)."""
def __init__(
self,
root: Path | None = None,
builtin_root: Path | None = BUILTIN_SKILLS_ROOT,
) -> None:
self._root = root or (get_path_service().get_workspace_dir() / "skills")
self._builtin_root = builtin_root
@property
def root(self) -> Path:
return self._root
# ── path helpers ────────────────────────────────────────────────────
def _validate_name(self, name: str) -> str:
candidate = (name or "").strip().lower()
if not _NAME_RE.match(candidate):
raise InvalidSkillNameError("Skill name must match ^[a-z0-9][a-z0-9-]{0,63}$")
return candidate
def _skill_dir(self, name: str) -> Path:
return self._root / self._validate_name(name)
def _skill_file(self, name: str) -> Path:
return self._skill_dir(name) / "SKILL.md"
def _resolve_skill_dir(self, name: str) -> tuple[Path, str] | None:
"""Locate a skill across layers: user first, then builtin."""
slug = self._validate_name(name)
user_dir = self._root / slug
if (user_dir / "SKILL.md").exists():
return user_dir, "user"
if self._builtin_root is not None:
builtin_dir = self._builtin_root / slug
if (builtin_dir / "SKILL.md").exists():
return builtin_dir, "builtin"
return None
# ── tag helpers ─────────────────────────────────────────────────────
@staticmethod
def _normalize_tag(raw: Any) -> str:
candidate = str(raw or "").strip().lower()
if not candidate:
raise InvalidTagError("Tag name must not be empty.")
if not _TAG_RE.match(candidate):
raise InvalidTagError(
"Tag must match ^[a-z0-9][a-z0-9- _]{0,31}$ (letters/digits/dash/space/underscore)."
)
return candidate
@staticmethod
def _dedupe_tags(values: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for v in values:
if v in seen:
continue
seen.add(v)
out.append(v)
return out
def _tags_path(self) -> Path:
return self._root / _TAGS_FILE
def _read_tag_vocab(self) -> list[str]:
path = self._tags_path()
if not path.exists():
return []
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
raw = data.get("tags") if isinstance(data, dict) else None
if not isinstance(raw, list):
return []
out: list[str] = []
for item in raw:
try:
out.append(self._normalize_tag(item))
except InvalidTagError:
continue
return self._dedupe_tags(out)
def _write_tag_vocab(self, tags: list[str]) -> None:
self._root.mkdir(parents=True, exist_ok=True)
payload = {"tags": self._dedupe_tags(tags)}
self._tags_path().write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _tags_from_meta(self, meta: dict[str, Any]) -> list[str]:
raw = meta.get("tags")
if not isinstance(raw, list):
return []
out: list[str] = []
for item in raw:
try:
out.append(self._normalize_tag(item))
except InvalidTagError:
continue
return self._dedupe_tags(out)
def _collect_skill_tags(self) -> list[str]:
"""Scan user skills and collect tags present in their frontmatter."""
found: list[str] = []
for info in self.list_skills():
if info.source != "user":
continue
for tag in info.tags:
if tag not in found:
found.append(tag)
return found
def _ensure_initialized_vocab(self) -> list[str]:
"""Seed default tags on first access and backfill any tags found on skills."""
vocab = self._read_tag_vocab()
existed = self._tags_path().exists()
if not existed:
vocab = list(_DEFAULT_TAGS)
union = self._dedupe_tags(vocab + self._collect_skill_tags())
if not existed or union != vocab:
self._write_tag_vocab(union)
return union
# ── parsing ─────────────────────────────────────────────────────────
@staticmethod
def _parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
match = _FRONTMATTER_RE.match(content)
if not match:
return {}, content
raw = match.group(1)
body = content[match.end() :]
try:
data = yaml.safe_load(raw) or {}
except yaml.YAMLError:
data = {}
if not isinstance(data, dict):
data = {}
return data, body
@staticmethod
def _requires_from_meta(meta: dict[str, Any]) -> dict[str, Any]:
raw = meta.get("requires")
return raw if isinstance(raw, dict) else {}
@classmethod
def _availability(cls, meta: dict[str, Any]) -> tuple[bool, list[str]]:
"""Check ``requires`` gates; return ``(available, missing-labels)``.
``bins`` are checked against the host PATH. Once the runner-sidecar
sandbox ships, exec-flavoured skills should prefer the
``sandbox`` gate over host ``bins`` (the runner image carries its
own binaries).
"""
requires = cls._requires_from_meta(meta)
missing: list[str] = []
for b in requires.get("bins") or []:
label = str(b).strip()
if label and not shutil.which(label):
missing.append(f"CLI: {label}")
for env_var in requires.get("env") or []:
label = str(env_var).strip()
if label and not os.environ.get(label):
missing.append(f"ENV: {label}")
sandbox = requires.get("sandbox")
if sandbox:
kind = "shell" if sandbox is True else str(sandbox).strip()
if kind and not _sandbox_available(kind):
missing.append(f"SANDBOX: {kind}")
return (not missing), missing
def _load_info(self, skill_dir: Path, source: str) -> SkillInfo | None:
file = skill_dir / "SKILL.md"
try:
text = file.read_text(encoding="utf-8")
except OSError:
return None
meta, _ = self._parse_frontmatter(text)
return SkillInfo(
name=skill_dir.name,
description=str(meta.get("description") or "").strip(),
tags=self._tags_from_meta(meta),
source=source,
)
# ── public read API ─────────────────────────────────────────────────
def list_skills(self) -> list[SkillInfo]:
"""All skills visible from this service: user layer + unshadowed builtin."""
out: list[SkillInfo] = []
seen: set[str] = set()
if self._root.exists():
for entry in sorted(self._root.iterdir()):
if not entry.is_dir() or not (entry / "SKILL.md").exists():
continue
if not _NAME_RE.match(entry.name):
continue
info = self._load_info(entry, "user")
if info is not None:
out.append(info)
seen.add(info.name)
if self._builtin_root is not None and self._builtin_root.exists():
for entry in sorted(self._builtin_root.iterdir()):
if not entry.is_dir() or not (entry / "SKILL.md").exists():
continue
if entry.name in seen or not _NAME_RE.match(entry.name):
continue
info = self._load_info(entry, "builtin")
if info is not None:
out.append(info)
return out
def get_detail(self, name: str) -> SkillDetail:
resolved = self._resolve_skill_dir(name)
if resolved is None:
raise SkillNotFoundError(name)
skill_dir, source = resolved
text = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
meta, _ = self._parse_frontmatter(text)
return SkillDetail(
name=skill_dir.name,
description=str(meta.get("description") or "").strip(),
content=text,
tags=self._tags_from_meta(meta),
source=source,
)
def read_skill_file(self, name: str, rel_path: str = "SKILL.md") -> str:
"""Read a file from inside a skill package (for the ``read_skill`` tool).
``rel_path`` is resolved strictly within the skill directory —
absolute paths and traversal segments are rejected. Content longer
than the read cap is truncated with an explicit marker.
"""
resolved = self._resolve_skill_dir(name)
if resolved is None:
raise SkillNotFoundError(name)
skill_dir, _source = resolved
candidate = (rel_path or "SKILL.md").strip() or "SKILL.md"
rel = Path(candidate)
if rel.is_absolute() or ".." in rel.parts:
raise InvalidSkillPathError(f"Illegal skill file path: {rel_path}")
target = (skill_dir / rel).resolve()
if not target.is_relative_to(skill_dir.resolve()):
raise InvalidSkillPathError(f"Illegal skill file path: {rel_path}")
if not target.is_file():
raise SkillNotFoundError(f"{name}/{candidate}")
text = target.read_text(encoding="utf-8", errors="replace")
if len(text) > _MAX_READ_CHARS:
text = text[:_MAX_READ_CHARS] + "\n\n[... truncated ...]"
return text
def list_skill_files(self, name: str) -> list[str]:
"""Relative paths of readable files inside a skill package."""
resolved = self._resolve_skill_dir(name)
if resolved is None:
raise SkillNotFoundError(name)
skill_dir, _source = resolved
files: list[str] = []
for path in sorted(skill_dir.rglob("*")):
if path.is_file() and not path.name.startswith("."):
files.append(str(path.relative_to(skill_dir)))
return files
# ── runtime loading (manifest + always) ─────────────────────────────
def summary_entries(self) -> list[SkillSummaryEntry]:
"""Manifest rows for every skill visible from this service."""
entries: list[SkillSummaryEntry] = []
for info in self.list_skills():
resolved = self._resolve_skill_dir(info.name)
if resolved is None:
continue
skill_dir, _source = resolved
meta, _ = self._parse_frontmatter((skill_dir / "SKILL.md").read_text(encoding="utf-8"))
available, missing = self._availability(meta)
entries.append(
SkillSummaryEntry(
name=info.name,
description=info.description,
available=available,
missing=missing,
always=bool(meta.get("always")),
)
)
return entries
def load_for_context(self, names: list[str]) -> str:
"""Render the given skills' full bodies into a system-prompt block.
Used for ``always: true`` skills only — everything else reaches the
model through the manifest + ``read_skill``.
"""
if not names:
return ""
parts: list[str] = []
for name in names:
try:
detail = self.get_detail(name)
except (SkillNotFoundError, InvalidSkillNameError):
continue
_, body = self._parse_frontmatter(detail.content)
body = body.strip()
if not body:
continue
parts.append(f"### Skill: {detail.name}\n\n{body}")
if not parts:
return ""
return (
"## Active Skills\n"
"Follow the playbooks below. They override generic defaults.\n\n"
+ "\n\n---\n\n".join(parts)
)
def load_always_for_context(self) -> str:
"""Eagerly render skills marked ``always: true`` (and available)."""
names = [e.name for e in self.summary_entries() if e.always and e.available]
return self.load_for_context(names)
# ── public write API (user layer only) ──────────────────────────────
def _assert_writable(self, slug: str) -> None:
"""Writes must target the user layer; builtin skills are read-only."""
if (self._root / slug / "SKILL.md").exists():
return
if self._builtin_root is not None and (self._builtin_root / slug / "SKILL.md").exists():
raise SkillReadOnlyError(f"Skill is builtin (read-only): {slug}")
raise SkillNotFoundError(slug)
def create(
self,
name: str,
description: str,
content: str,
tags: list[str] | None = None,
) -> SkillInfo:
slug = self._validate_name(name)
target_dir = self._skill_dir(slug)
if target_dir.exists():
raise SkillExistsError(slug)
clean_tags = self._validate_tag_list(tags)
body = self._normalize_content(
slug,
description,
content,
tags=clean_tags,
)
target_dir.mkdir(parents=True, exist_ok=False)
self._skill_file(slug).write_text(body, encoding="utf-8")
self._merge_tags_into_vocab(clean_tags)
return SkillInfo(name=slug, description=description.strip(), tags=clean_tags)
def update(
self,
name: str,
*,
description: str | None = None,
content: str | None = None,
rename_to: str | None = None,
tags: list[str] | None = None,
) -> SkillInfo:
slug = self._validate_name(name)
self._assert_writable(slug)
target_dir = self._skill_dir(slug)
if content is not None:
text = content
else:
text = self._skill_file(slug).read_text(encoding="utf-8")
if description is not None:
text = self._rewrite_frontmatter(text, description=description.strip())
clean_tags: list[str] | None = None
if tags is not None:
clean_tags = self._validate_tag_list(tags)
text = self._rewrite_frontmatter(text, tags=clean_tags)
meta, _ = self._parse_frontmatter(text)
final_description = str(meta.get("description") or "").strip()
final_tags = self._tags_from_meta(meta)
if rename_to and rename_to != slug:
new_slug = self._validate_name(rename_to)
new_dir = self._skill_dir(new_slug)
if new_dir.exists():
raise SkillExistsError(new_slug)
text = self._rewrite_frontmatter(text, name=new_slug)
target_dir.rename(new_dir)
slug = new_slug
target_dir = new_dir
self._skill_file(slug).write_text(text, encoding="utf-8")
if clean_tags is not None:
self._merge_tags_into_vocab(clean_tags)
return SkillInfo(name=slug, description=final_description, tags=final_tags)
def delete(self, name: str) -> None:
slug = self._validate_name(name)
self._assert_writable(slug)
shutil.rmtree(self._skill_dir(slug))
self._drop_hub_origin(slug)
# ── imported skill packages (hub) ────────────────────────────────────
def install_tree(
self,
source_dir: str | Path,
*,
rename_to: str | None = None,
fallback_description: str | None = None,
origin: dict[str, Any] | None = None,
force: bool = False,
extra_tags: list[str] | None = None,
) -> SkillInstallResult:
"""Import an extracted skill package directory into the user layer.
Unlike :meth:`create` (single authored ``SKILL.md``), this takes a
whole package tree — ``SKILL.md`` plus support files — as produced by
an external hub download, and applies the import policy:
* frontmatter is adapted: ``name`` is forced to the slug, flat
``bins:``/``env:`` keys (Agent-Skills style) fold into
``requires.*``, and ``always:`` is **stripped** — an imported skill
must never eager-inject itself into the system prompt;
* support files pass a suffix whitelist plus size/count caps;
symlinks abort the import (see :meth:`_copy_support_tree`);
* the tree is staged and swapped in atomically, so a failed import
never leaves a half-written skill;
* ``extra_tags`` are merged into the package's own tags (the hub
installer stamps the source hub, e.g. ``eduhub``, this way);
* ``origin`` (hub provenance) is recorded in ``.hub-lock.json``.
"""
source = Path(source_dir).resolve()
skill_md = source / "SKILL.md"
if not skill_md.is_file():
raise SkillImportError("Package has no SKILL.md at its root.")
if skill_md.stat().st_size > _IMPORT_MAX_FILE_BYTES:
raise SkillImportError("SKILL.md exceeds the import size limit.")
text = skill_md.read_text(encoding="utf-8", errors="replace")
meta, body = self._parse_frontmatter(text)
slug = self._validate_name(self._slugify(str(rename_to or meta.get("name") or source.name)))
description = (
str(meta.get("description") or "").strip() or (fallback_description or "").strip()
)
if not description:
raise SkillImportError("SKILL.md has no description and no fallback was provided.")
tags = self._validate_tag_list(
(meta.get("tags") if isinstance(meta.get("tags"), list) else [])
+ list(extra_tags or [])
)
target_dir = self._skill_dir(slug)
if target_dir.exists() and not force:
raise SkillExistsError(slug)
header = yaml.safe_dump(
self._compose_imported_frontmatter(meta, slug=slug, description=description, tags=tags),
sort_keys=False,
allow_unicode=True,
).strip()
adapted = f"---\n{header}\n---\n\n{body.lstrip()}".rstrip() + "\n"
staging = self._root / f".install-{slug}.tmp"
if staging.exists():
shutil.rmtree(staging)
try:
staging.mkdir(parents=True)
skipped = self._copy_support_tree(source, staging)
(staging / "SKILL.md").write_text(adapted, encoding="utf-8")
if target_dir.exists():
shutil.rmtree(target_dir)
staging.rename(target_dir)
finally:
if staging.exists():
shutil.rmtree(staging, ignore_errors=True)
self._merge_tags_into_vocab(tags)
if origin is not None:
self.record_hub_origin(slug, origin)
else:
self._drop_hub_origin(slug)
return SkillInstallResult(
info=SkillInfo(name=slug, description=description, tags=tags),
skipped=skipped,
)
@staticmethod
def _slugify(raw: str) -> str:
candidate = re.sub(r"[^a-z0-9-]+", "-", (raw or "").strip().lower())
return re.sub(r"-{2,}", "-", candidate).strip("-")[:64]
@staticmethod
def _as_str_list(value: Any) -> list[str]:
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
if isinstance(value, str) and value.strip():
return [value.strip()]
return []
def _compose_imported_frontmatter(
self,
meta: dict[str, Any],
*,
slug: str,
description: str,
tags: list[str],
) -> dict[str, Any]:
"""Frontmatter for an imported skill: our schema, their extras.
Flat ``bins``/``env`` (the Agent-Skills/ClawHub spelling) merge into
``requires.*``; ``always`` is dropped — letting a downloaded package
force itself into every system prompt would be an injection vector.
Unknown keys (version, license, metadata, …) ride along untouched.
"""
requires_raw = meta.get("requires")
requires = dict(requires_raw) if isinstance(requires_raw, dict) else {}
bins = self._dedupe_tags(
self._as_str_list(requires.get("bins")) + self._as_str_list(meta.get("bins"))
)
env = self._dedupe_tags(
self._as_str_list(requires.get("env")) + self._as_str_list(meta.get("env"))
)
if bins:
requires["bins"] = bins
if env:
requires["env"] = env
out: dict[str, Any] = {"name": slug, "description": description}
if tags:
out["tags"] = list(tags)
if requires:
out["requires"] = requires
for key, value in meta.items():
if key in {"name", "description", "tags", "requires", "bins", "env", "always"}:
continue
out[key] = value
return out
def _copy_support_tree(self, source: Path, staging: Path) -> list[tuple[str, str]]:
"""Copy a package's support files into the staging dir, gated.
Dotfiles/dot-dirs are silently ignored; disallowed suffixes and
oversized files are skipped and reported; symlinks abort the whole
import (a link can alias content outside the package). Copies go
through ``shutil.copyfile`` so no permission bits (notably exec)
survive the import.
"""
skipped: list[tuple[str, str]] = []
count = 0
total = 0
for path in sorted(source.rglob("*")):
rel = path.relative_to(source)
if any(part.startswith(".") for part in rel.parts):
continue
if path.is_symlink():
raise SkillImportError(f"Symbolic links are not allowed: {rel}")
if path.is_dir():
continue
if rel.as_posix() == "SKILL.md":
continue # rewritten separately after frontmatter adaptation
if path.suffix.lower() not in _IMPORT_ALLOWED_SUFFIXES:
skipped.append((rel.as_posix(), "file type not allowed"))
continue
size = path.stat().st_size
if size > _IMPORT_MAX_FILE_BYTES:
skipped.append((rel.as_posix(), "file exceeds size limit"))
continue
count += 1
total += size
if count > _IMPORT_MAX_FILES:
raise SkillImportError("Package has too many files.")
if total > _IMPORT_MAX_TOTAL_BYTES:
raise SkillImportError("Package exceeds the total size limit.")
dest = staging / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(path, dest)
return skipped
# ── hub provenance (.hub-lock.json) ──────────────────────────────────
def _hub_lock_path(self) -> Path:
return self._root / _HUB_LOCK_FILE
def _read_hub_lock(self) -> dict[str, dict[str, Any]]:
path = self._hub_lock_path()
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
if not isinstance(data, dict):
return {}
return {
key: value
for key, value in data.items()
if isinstance(key, str) and isinstance(value, dict)
}
def _write_hub_lock(self, data: dict[str, dict[str, Any]]) -> None:
self._root.mkdir(parents=True, exist_ok=True)
self._hub_lock_path().write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def record_hub_origin(self, name: str, origin: dict[str, Any]) -> None:
slug = self._validate_name(name)
lock = self._read_hub_lock()
lock[slug] = dict(origin)
self._write_hub_lock(lock)
def hub_origin(self, name: str) -> dict[str, Any] | None:
try:
slug = self._validate_name(name)
except InvalidSkillNameError:
return None
return self._read_hub_lock().get(slug)
def _drop_hub_origin(self, slug: str) -> None:
lock = self._read_hub_lock()
if slug in lock:
del lock[slug]
self._write_hub_lock(lock)
# ── tag management API ─────────────────────────────────────────────
def list_tags(self) -> list[str]:
return self._ensure_initialized_vocab()
def create_tag(self, name: str) -> str:
tag = self._normalize_tag(name)
vocab = self._ensure_initialized_vocab()
if tag in vocab:
raise TagExistsError(tag)
self._write_tag_vocab(vocab + [tag])
return tag
def rename_tag(self, old: str, new: str) -> str:
old_tag = self._normalize_tag(old)
new_tag = self._normalize_tag(new)
vocab = self._ensure_initialized_vocab()
if old_tag not in vocab:
raise TagNotFoundError(old_tag)
if new_tag != old_tag and new_tag in vocab:
raise TagExistsError(new_tag)
if new_tag == old_tag:
return old_tag
new_vocab = [new_tag if t == old_tag else t for t in vocab]
self._write_tag_vocab(new_vocab)
# Cascade: rewrite frontmatter on every user skill that used the old tag.
self._replace_tag_in_skills(old_tag, new_tag)
return new_tag
def delete_tag(self, name: str) -> None:
tag = self._normalize_tag(name)
vocab = self._ensure_initialized_vocab()
if tag not in vocab:
raise TagNotFoundError(tag)
new_vocab = [t for t in vocab if t != tag]
self._write_tag_vocab(new_vocab)
self._replace_tag_in_skills(tag, None)
# ── internal tag helpers ───────────────────────────────────────────
def _validate_tag_list(self, tags: list[str] | None) -> list[str]:
if not tags:
return []
cleaned: list[str] = []
for raw in tags:
try:
cleaned.append(self._normalize_tag(raw))
except InvalidTagError:
continue
return self._dedupe_tags(cleaned)
def _merge_tags_into_vocab(self, new_tags: list[str]) -> None:
if not new_tags:
# Still trigger init so the vocab file exists after first write.
self._ensure_initialized_vocab()
return
vocab = self._ensure_initialized_vocab()
merged = self._dedupe_tags(vocab + new_tags)
if merged != vocab:
self._write_tag_vocab(merged)
def _replace_tag_in_skills(self, old_tag: str, new_tag: str | None) -> None:
if not self._root.exists():
return
for entry in sorted(self._root.iterdir()):
if not entry.is_dir() or not (entry / "SKILL.md").exists():
continue
info = self._load_info(entry, "user")
if info is None or old_tag not in info.tags:
continue
updated: list[str] = []
for t in info.tags:
if t == old_tag:
if new_tag and new_tag not in updated:
updated.append(new_tag)
elif t not in updated:
updated.append(t)
text = (entry / "SKILL.md").read_text(encoding="utf-8")
new_text = self._rewrite_frontmatter(text, tags=updated)
(entry / "SKILL.md").write_text(new_text, encoding="utf-8")
# ── content helpers ────────────────────────────────────────────────
def _normalize_content(
self,
name: str,
description: str,
content: str,
*,
tags: list[str] | None = None,
) -> str:
"""Ensure the saved file has a valid frontmatter block with ``name``/``description``.
If the user-provided ``content`` already has frontmatter we patch the
``name``, ``description`` and ``tags`` fields; otherwise we synthesise
a header.
"""
text = content if content is not None else ""
if _FRONTMATTER_RE.match(text):
text = self._rewrite_frontmatter(
text,
name=name,
description=description.strip(),
tags=tags,
)
return text
payload: dict[str, Any] = {
"name": name,
"description": description.strip(),
}
if tags:
payload["tags"] = list(tags)
header = yaml.safe_dump(
payload,
sort_keys=False,
allow_unicode=True,
).strip()
body = text.lstrip()
return f"---\n{header}\n---\n\n{body}".rstrip() + "\n"
def _rewrite_frontmatter(
self,
text: str,
*,
name: str | None = None,
description: str | None = None,
tags: list[str] | None = None,
) -> str:
meta, body = self._parse_frontmatter(text)
if name is not None:
meta["name"] = name
if description is not None:
meta["description"] = description
if tags is not None:
if tags:
meta["tags"] = list(tags)
elif "tags" in meta:
meta.pop("tags", None)
if not meta:
return text
header = yaml.safe_dump(meta, sort_keys=False, allow_unicode=True).strip()
return f"---\n{header}\n---\n\n{body.lstrip()}".rstrip() + "\n"
def render_skills_manifest(entries: list[SkillSummaryEntry]) -> str:
"""Render manifest rows into the system-prompt Skills block.
``always`` entries are excluded — their full bodies are already injected
eagerly, so listing them again would only waste tokens. Duplicate names
keep their first occurrence (caller orders user > admin > builtin).
"""
seen: set[str] = set()
lines: list[str] = []
for entry in entries:
if entry.always or entry.name in seen:
continue
seen.add(entry.name)
suffix = ""
if not entry.available:
suffix = f" (unavailable: {', '.join(entry.missing)})"
description = entry.description or entry.name
lines.append(f"- **{entry.name}** — {description}{suffix}")
if not lines:
return ""
return (
"## Skills\n"
"Specialised playbooks available on demand. When a task matches a "
"skill's description, call `read_skill` with its name BEFORE "
"attempting the task, then follow the returned instructions. Skills "
"marked unavailable cannot be used until their requirements are met.\n\n" + "\n".join(lines)
)
_instances: dict[str, SkillService] = {}
def get_skill_service() -> SkillService:
root = (get_path_service().get_workspace_dir() / "skills").resolve()
key = str(root)
if key not in _instances:
_instances[key] = SkillService(root=root)
return _instances[key]
__all__ = [
"BUILTIN_SKILLS_ROOT",
"InvalidSkillNameError",
"InvalidSkillPathError",
"InvalidTagError",
"SkillDetail",
"SkillExistsError",
"SkillImportError",
"SkillInfo",
"SkillInstallResult",
"SkillNotFoundError",
"SkillReadOnlyError",
"SkillService",
"SkillSummaryEntry",
"TagExistsError",
"TagNotFoundError",
"get_skill_service",
"render_skills_manifest",
]