Files
hive/core/framework/skills/authoring.py
T
2026-04-28 10:43:54 -07:00

204 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Shared skill authoring primitives.
Validates and materializes a skill folder. Used by three callers:
1. Queen's ``create_colony`` tool (``queen_lifecycle_tools.py``) — inline
content passed by the queen during colony creation.
2. HTTP POST / PUT routes under ``/api/**/skills`` — UI-driven creation.
3. Future ``create_learned_skill`` tool — runtime learning.
Keeping the validators and writer here ensures the three paths share one
authority; changes to the name regex or frontmatter layout happen in one
place.
"""
from __future__ import annotations
import logging
import re
import shutil
from dataclasses import dataclass, field
from pathlib import Path
logger = logging.getLogger(__name__)
# Framework skill names include dots (``hive.note-taking``), so the
# validator needs to allow them even though the queen's ``create_colony``
# tool historically forbade dots. User-created skills without dots still
# pass; the dot cap just prevents us from rejecting existing framework
# names when the UI toggles them via ``validate_skill_name``.
_SKILL_NAME_RE = re.compile(r"^[a-z0-9.-]+$")
_MAX_NAME_LEN = 64
_MAX_DESC_LEN = 1024
@dataclass
class SkillFile:
"""Supporting file bundled with a skill (relative path + content)."""
rel_path: Path
content: str
@dataclass
class SkillDraft:
"""Validated skill content ready to be written to disk."""
name: str
description: str
body: str
files: list[SkillFile] = field(default_factory=list)
@property
def skill_md_text(self) -> str:
"""Assemble the final SKILL.md text (frontmatter + body)."""
body_norm = self.body.rstrip() + "\n"
return f"---\nname: {self.name}\ndescription: {self.description}\n---\n\n{body_norm}"
def validate_skill_name(raw: str) -> tuple[str | None, str | None]:
"""Return ``(normalized_name, error)``. Either side may be None."""
name = (raw or "").strip() if isinstance(raw, str) else ""
if not name:
return None, "skill_name is required"
if not _SKILL_NAME_RE.match(name):
return None, f"skill_name '{name}' must match [a-z0-9-] pattern"
if name.startswith("-") or name.endswith("-") or "--" in name:
return None, f"skill_name '{name}' has leading/trailing/consecutive hyphens"
if len(name) > _MAX_NAME_LEN:
return None, f"skill_name '{name}' exceeds {_MAX_NAME_LEN} chars"
return name, None
def validate_description(raw: str) -> tuple[str | None, str | None]:
desc = (raw or "").strip() if isinstance(raw, str) else ""
if not desc:
return None, "skill_description is required"
if len(desc) > _MAX_DESC_LEN:
return None, f"skill_description must be 1{_MAX_DESC_LEN} chars"
# Frontmatter descriptions are line-oriented — the parser reads one value.
if "\n" in desc or "\r" in desc:
return None, "skill_description must be a single line (no newlines)"
return desc, None
def validate_files(raw: list[dict] | None) -> tuple[list[SkillFile] | None, str | None]:
if not raw:
return [], None
if not isinstance(raw, list):
return None, "skill_files must be an array"
out: list[SkillFile] = []
for entry in raw:
if not isinstance(entry, dict):
return None, "each skill_files entry must be an object with 'path' and 'content'"
rel_raw = entry.get("path")
content = entry.get("content")
if not isinstance(rel_raw, str) or not rel_raw.strip():
return None, "skill_files entry missing non-empty 'path'"
if not isinstance(content, str):
return None, f"skill_files entry '{rel_raw}' missing string 'content'"
rel_stripped = rel_raw.strip()
# Allow './foo' but reject '/foo' — relativizing absolute paths silently
# has bitten other tools; make the intent loud instead.
if rel_stripped.startswith("./"):
rel_stripped = rel_stripped[2:]
rel_path = Path(rel_stripped)
if rel_stripped.startswith("/") or rel_path.is_absolute() or ".." in rel_path.parts:
return None, f"skill_files path '{rel_raw}' must be relative and inside the skill folder"
if rel_path.as_posix() == "SKILL.md":
return None, "skill_files must not contain SKILL.md — pass skill_body instead"
out.append(SkillFile(rel_path=rel_path, content=content))
return out, None
def build_draft(
*,
skill_name: str,
skill_description: str,
skill_body: str,
skill_files: list[dict] | None = None,
) -> tuple[SkillDraft | None, str | None]:
"""Validate all inputs and return an immutable draft ready for writing."""
name, err = validate_skill_name(skill_name)
if err or name is None:
return None, err
desc, err = validate_description(skill_description)
if err or desc is None:
return None, err
body = skill_body if isinstance(skill_body, str) else ""
if not body.strip():
return None, (
"skill_body is required — the operational procedure the colony worker needs to run this job unattended"
)
files, err = validate_files(skill_files)
if err or files is None:
return None, err
return SkillDraft(name=name, description=desc, body=body, files=list(files)), None
def write_skill(
draft: SkillDraft,
*,
target_root: Path,
replace_existing: bool = True,
) -> tuple[Path | None, str | None, bool]:
"""Write the draft under ``target_root/{draft.name}/``.
``target_root`` is the parent scope dir (e.g.
``~/.hive/agents/queens/{id}/skills`` or
``{colony_dir}/skills``). The function creates it if needed.
Returns ``(installed_path, error, replaced)``. On success ``error`` is
``None``; on failure ``installed_path`` is ``None`` and the target is
left as it was before the call (best-effort).
When ``replace_existing=False`` and the target dir already exists,
the write is refused with a non-fatal error (caller decides whether
to surface it as a 409 or a warning).
"""
try:
target_root.mkdir(parents=True, exist_ok=True)
except OSError as e:
return None, f"failed to create skills root: {e}", False
target = target_root / draft.name
replaced = False
try:
if target.exists():
if not replace_existing:
return None, f"skill '{draft.name}' already exists", False
# Remove the old dir outright so stale files from a prior
# version don't linger alongside the new ones.
replaced = True
shutil.rmtree(target)
target.mkdir(parents=True, exist_ok=False)
(target / "SKILL.md").write_text(draft.skill_md_text, encoding="utf-8")
for sf in draft.files:
full_path = target / sf.rel_path
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(sf.content, encoding="utf-8")
except OSError as e:
return None, f"failed to write skill folder {target}: {e}", replaced
return target, None, replaced
def remove_skill(target_root: Path, skill_name: str) -> tuple[bool, str | None]:
"""Rm-tree the skill directory under ``target_root/{skill_name}/``.
Returns ``(removed, error)``. ``removed=False, error=None`` means
the directory didn't exist (idempotent). Name is validated on the
way in so an attacker with UI access can't traverse out of the
scope root.
"""
name, err = validate_skill_name(skill_name)
if err or name is None:
return False, err
target = target_root / name
if not target.exists():
return False, None
try:
shutil.rmtree(target)
except OSError as e:
return False, f"failed to remove skill folder {target}: {e}"
return True, None