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

1151 lines
46 KiB
Python

"""HTTP routes for per-queen / per-colony skill control + aggregated library.
Three parallel surfaces:
1. Per-queen routes
GET /api/queen/{queen_id}/skills
POST /api/queen/{queen_id}/skills
PATCH /api/queen/{queen_id}/skills/{skill_name}
PUT /api/queen/{queen_id}/skills/{skill_name}/body
DELETE /api/queen/{queen_id}/skills/{skill_name}
POST /api/queen/{queen_id}/skills/reload
2. Per-colony routes (same shape, but keyed by colony_name)
GET / POST / PATCH / PUT / DELETE / reload
3. Aggregated library (powers the Skills Library page)
GET /api/skills -- full catalog + inversion (visible_to.*)
GET /api/skills/{name} -- full body + file listing for drawer view
GET /api/skills/scopes -- {queens: [...], colonies: [...]}
POST /api/skills/upload -- multipart (.md or .zip) into a named scope
Live managers are reloaded when an override mutation occurs so in-flight
queens/workers pick up the new catalog on their next iteration via the
dynamic-prompt providers wired in ``colony_runtime`` and
``queen_orchestrator``.
"""
from __future__ import annotations
import io
import logging
import re
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from aiohttp import web
from framework.config import COLONIES_DIR, QUEENS_DIR
from framework.skills.authoring import (
build_draft,
remove_skill as authoring_remove_skill,
validate_skill_name,
write_skill,
)
from framework.skills.discovery import DiscoveryConfig, ExtraScope, SkillDiscovery
from framework.skills.manager import SkillsManager, SkillsManagerConfig
from framework.skills.overrides import (
OverrideEntry,
Provenance,
SkillOverrideStore,
utc_now,
)
from framework.skills.parser import ParsedSkill
logger = logging.getLogger(__name__)
# Cap uploaded payloads to avoid the aiohttp multipart reader pulling
# megabytes into memory. 2 MB is generous for a SKILL.md + a handful
# of supporting files.
_MAX_UPLOAD_BYTES = 2 * 1024 * 1024
_ZIP_MAGIC = b"PK\x03\x04"
# ---------------------------------------------------------------------------
# Scope resolution
# ---------------------------------------------------------------------------
@dataclass
class SkillScope:
"""Everything a handler needs to mutate a scope's skill surface."""
kind: str # "queen" | "colony"
target_id: str # queen_id or colony_name
write_dir: Path # where SKILL.md folders live
overrides_path: Path # where the JSON store lives
store: SkillOverrideStore
# Live runtimes whose SkillsManager must be reloaded on mutation.
affected_runtimes: list = field(default_factory=list)
# The SkillsManager used by GET to enumerate skills. Queen scope
# prefers a live DM session's manager; colony scope uses the colony
# runtime. When no runtime is live we build an ad-hoc manager.
manager: SkillsManager | None = None
def _ensure_queens_known() -> None:
"""Materialize default queen profiles so GET works on a cold install."""
from framework.agents.queen.queen_profiles import ensure_default_queens
try:
ensure_default_queens()
except Exception:
logger.debug("ensure_default_queens failed (non-fatal)", exc_info=True)
class _ManagerReloadAdapter:
"""Makes a bare ``SkillsManager`` look like a runtime to ``_reload_scope``.
``_reload_scope`` calls ``await rt.reload_skills()`` on every entry in
``affected_runtimes``. Live queen DM sessions expose their manager on
``phase_state.skills_manager`` but don't have a runtime wrapper, so
we provide this thin shim so PATCHes reach them with the same call.
"""
def __init__(self, skills_manager: SkillsManager) -> None:
self._mgr = skills_manager
@property
def skills_manager(self) -> SkillsManager:
return self._mgr
async def reload_skills(self) -> dict[str, Any]:
async with self._mgr.mutation_lock:
self._mgr.reload()
return {"catalog_chars": len(self._mgr.skills_catalog_prompt)}
def _queen_scope(manager: Any, queen_id: str) -> SkillScope | None:
_ensure_queens_known()
queen_home = QUEENS_DIR / queen_id
if not queen_home.is_dir():
# queen_profiles only creates dirs for *known* queen ids. An unknown
# id means the caller typed something wrong.
return None
overrides_path = queen_home / "skills_overrides.json"
store = SkillOverrideStore.load(overrides_path, scope_label=f"queen:{queen_id}")
write_dir = queen_home / "skills"
# Always build a fresh admin manager for GET so enumeration reflects
# the current disk state (including newly-installed preset skills).
# The live queen-session manager caches ``_all_skills`` at load time
# and only refreshes on explicit reload or file-watch event — reusing
# it here means newly-bundled skills stay invisible until a restart.
admin_manager = _build_admin_manager(queen_id=queen_id)
runtimes: list = []
try:
for colony in manager.iter_colony_runtimes(queen_id=queen_id): # type: ignore[union-attr]
runtimes.append(colony)
# Also collect live DM-session managers as reload targets so a
# PATCH reaches running queens, even though we enumerate from
# the admin manager.
for session in manager.iter_queen_sessions(queen_id): # type: ignore[union-attr]
phase_state = getattr(session, "phase_state", None)
if phase_state is None:
continue
skills_mgr = getattr(phase_state, "skills_manager", None)
if isinstance(skills_mgr, SkillsManager):
runtimes.append(_ManagerReloadAdapter(skills_mgr))
except Exception:
logger.debug("queen scope: live manager lookup failed", exc_info=True)
return SkillScope(
kind="queen",
target_id=queen_id,
write_dir=write_dir,
overrides_path=overrides_path,
store=store,
affected_runtimes=runtimes,
manager=admin_manager,
)
def _colony_scope(manager: Any, colony_name: str) -> SkillScope | None:
colony_home = COLONIES_DIR / colony_name
if not colony_home.is_dir():
return None
# Read colony metadata to find the owning queen, so cascades and
# inherited-from-queen listing work on GET.
queen_id: str | None = None
try:
from framework.host.colony_metadata import load_colony_metadata
meta = load_colony_metadata(colony_name)
queen_id = meta.get("queen_name") or None
except Exception:
logger.debug("colony metadata lookup failed for %s", colony_name, exc_info=True)
overrides_path = colony_home / "skills_overrides.json"
store = SkillOverrideStore.load(overrides_path, scope_label=f"colony:{colony_name}")
write_dir = colony_home / "skills"
admin_manager = _build_admin_manager(queen_id=queen_id, colony_name=colony_name)
runtimes: list = []
try:
for colony in manager.iter_colony_runtimes(colony_name=colony_name): # type: ignore[union-attr]
runtimes.append(colony)
except Exception:
logger.debug("colony scope: live manager lookup failed", exc_info=True)
return SkillScope(
kind="colony",
target_id=colony_name,
write_dir=write_dir,
overrides_path=overrides_path,
store=store,
affected_runtimes=runtimes,
manager=admin_manager,
)
def _build_admin_manager(
*,
queen_id: str | None = None,
colony_name: str | None = None,
) -> SkillsManager:
"""Build a read-only SkillsManager for GET when no live session exists.
Intentionally leaves ``project_root`` unset even for a colony: the
colony's ``skills/`` directory (and the legacy ``.hive/skills/`` for
pre-flatten colonies) is surfaced via the ``colony_ui`` extra scope.
Routing it through ``project_root`` would double-scan the same dir,
and last-wins collision resolution would retag the skills as
``source_scope="project"`` — which flips the provenance fallback to
``PROJECT_DROPPED`` and drops ``editable`` to ``False`` for anything
without an explicit override-store entry.
"""
extras: list[ExtraScope] = []
queen_overrides_path: Path | None = None
colony_overrides_path: Path | None = None
if queen_id:
queen_home = QUEENS_DIR / queen_id
queen_overrides_path = queen_home / "skills_overrides.json"
extras.append(ExtraScope(directory=queen_home / "skills", label="queen_ui", priority=2))
if colony_name:
colony_home = COLONIES_DIR / colony_name
colony_overrides_path = colony_home / "skills_overrides.json"
# Surface both the new flat path (where new skills are written) and
# the legacy nested path (left intact for pre-flatten colonies). UI
# writes always target the flat path; reads see both.
extras.append(ExtraScope(directory=colony_home / "skills", label="colony_ui", priority=3))
extras.append(ExtraScope(directory=colony_home / ".hive" / "skills", label="colony_ui", priority=3))
cfg = SkillsManagerConfig(
queen_id=queen_id,
queen_overrides_path=queen_overrides_path,
colony_name=colony_name,
colony_overrides_path=colony_overrides_path,
extra_scope_dirs=extras,
project_root=None,
skip_community_discovery=True,
interactive=False,
)
mgr = SkillsManager(cfg)
mgr.load()
return mgr
# ---------------------------------------------------------------------------
# Serializers
# ---------------------------------------------------------------------------
_EDITABLE_PROVENANCE = {
Provenance.USER_UI_CREATED,
Provenance.QUEEN_CREATED,
Provenance.LEARNED_RUNTIME,
}
def _resolve_provenance(
skill: ParsedSkill,
queen_store: SkillOverrideStore | None,
colony_store: SkillOverrideStore | None,
) -> tuple[Provenance, OverrideEntry | None]:
"""Override-store entry wins, otherwise fall back to source_scope inference.
``OTHER`` entries carry toggle/notes state but don't claim an
origin — we still return the inferred provenance for those so the
UI shows something meaningful instead of a generic badge.
"""
# Collect the entry (if any) before deciding which provenance to report.
# A FRAMEWORK-stamped entry on a skill that doesn't actually live in the
# framework scope was written by the old buggy PATCH handler; treat it
# like OTHER so the inference below reports an honest provenance.
store_entry: OverrideEntry | None = None
for store in (colony_store, queen_store):
if store is None:
continue
entry = store.get(skill.name)
if entry is not None:
store_entry = entry
stamped = entry.provenance
# Heal a FRAMEWORK stamp that doesn't match the actual scope —
# preset/user/colony skills got stamped FRAMEWORK by the old
# PATCH default. Leave a legit framework-scoped skill alone.
if stamped == Provenance.FRAMEWORK and skill.source_scope not in {"framework"}:
stamped = Provenance.OTHER
if stamped != Provenance.OTHER:
return stamped, entry
break
# Infer from scope label. ``colony_ui`` with no informative entry
# is only reachable via create_colony() since the UI POST path
# always stamps USER_UI_CREATED.
if skill.source_scope == "framework":
return Provenance.FRAMEWORK, store_entry
if skill.source_scope == "preset":
return Provenance.PRESET, store_entry
if skill.source_scope == "user":
return Provenance.USER_DROPPED, store_entry
if skill.source_scope == "queen_ui":
return Provenance.USER_UI_CREATED, store_entry
if skill.source_scope == "colony_ui":
return Provenance.QUEEN_CREATED, store_entry
return Provenance.PROJECT_DROPPED, store_entry
def _effective_enabled(
skill: ParsedSkill,
queen_store: SkillOverrideStore | None,
colony_store: SkillOverrideStore | None,
) -> bool:
# Mirrors ``SkillsManager._apply_overrides`` so the UI's "enabled" column
# matches what the queen actually sees in her prompt. Colony explicit wins
# over queen explicit; either explicit wins over preset-off-by-default and
# over the ``all_defaults_disabled`` master switch.
for store in (colony_store, queen_store):
if store is None:
continue
entry = store.get(skill.name)
if entry is not None and entry.enabled is not None:
return entry.enabled
# Preset-scope capability packs ship OFF; they only appear in the queen's
# catalog after an explicit per-queen/colony opt-in.
if skill.source_scope == "preset":
return False
for store in (colony_store, queen_store):
if store is not None and store.all_defaults_disabled and skill.source_scope == "framework":
return False
return True
def _serialize_skill(
skill: ParsedSkill,
*,
queen_store: SkillOverrideStore | None,
colony_store: SkillOverrideStore | None,
) -> dict[str, Any]:
provenance, entry = _resolve_provenance(skill, queen_store, colony_store)
editable = provenance in _EDITABLE_PROVENANCE
return {
"name": skill.name,
"description": skill.description,
"source_scope": skill.source_scope,
"provenance": str(provenance),
"enabled": _effective_enabled(skill, queen_store, colony_store),
"editable": editable,
"deletable": editable,
"location": skill.location,
"base_dir": skill.base_dir,
"visibility": skill.visibility,
"trust": entry.trust if entry else None,
"created_at": entry.created_at.isoformat() if (entry and entry.created_at) else None,
"created_by": entry.created_by if entry else None,
"notes": entry.notes if entry else None,
"param_overrides": dict(entry.param_overrides) if entry else {},
}
# ---------------------------------------------------------------------------
# GET handlers
# ---------------------------------------------------------------------------
async def handle_list_queen_skills(request: web.Request) -> web.Response:
manager = request.app.get("manager")
queen_id = request.match_info["queen_id"]
scope = _queen_scope(manager, queen_id)
if scope is None:
return web.json_response({"error": f"queen '{queen_id}' not found"}, status=404)
mgr = scope.manager
assert mgr is not None
skills = [
_serialize_skill(s, queen_store=scope.store, colony_store=None) for s in mgr.enumerate_skills_with_source()
]
skills.sort(key=lambda r: r["name"])
return web.json_response(
{
"queen_id": queen_id,
"all_defaults_disabled": scope.store.all_defaults_disabled,
"skills": skills,
}
)
async def handle_list_colony_skills(request: web.Request) -> web.Response:
manager = request.app.get("manager")
colony_name = request.match_info["colony_name"]
scope = _colony_scope(manager, colony_name)
if scope is None:
return web.json_response({"error": f"colony '{colony_name}' not found"}, status=404)
mgr = scope.manager
assert mgr is not None
# Queen store contributes cascade inheritance — load it so provenance /
# enabled resolution matches what the colony actually sees.
queen_store = None
queen_id: str | None = None
try:
from framework.host.colony_metadata import load_colony_metadata
queen_id = load_colony_metadata(colony_name).get("queen_name") or None
except Exception:
queen_id = None
if queen_id:
queen_store = SkillOverrideStore.load(
QUEENS_DIR / queen_id / "skills_overrides.json",
scope_label=f"queen:{queen_id}",
)
all_skills = mgr.enumerate_skills_with_source()
rows = [_serialize_skill(s, queen_store=queen_store, colony_store=scope.store) for s in all_skills]
rows.sort(key=lambda r: r["name"])
inherited = [s.name for s in all_skills if s.source_scope == "queen_ui"]
return web.json_response(
{
"colony_name": colony_name,
"queen_id": queen_id,
"all_defaults_disabled": scope.store.all_defaults_disabled,
"skills": rows,
"inherited_from_queen": sorted(inherited),
}
)
# ---------------------------------------------------------------------------
# Aggregated library
# ---------------------------------------------------------------------------
async def handle_list_all_skills(request: web.Request) -> web.Response:
"""Global catalog: every skill in every scope + inversion ``visible_to``."""
# Enumerate queens and colonies by walking the standard dirs.
_ensure_queens_known()
queen_ids = (
sorted(p.name for p in QUEENS_DIR.glob("*") if (p / "profile.yaml").exists()) if QUEENS_DIR.is_dir() else []
)
colony_names: list[str] = []
if COLONIES_DIR.is_dir():
colony_names = sorted(p.name for p in COLONIES_DIR.iterdir() if p.is_dir())
# Build one admin manager that covers every scope — expensive on cold
# boot but cached thanks to the parser's per-file reads being cheap.
extras: list[ExtraScope] = []
for qid in queen_ids:
extras.append(ExtraScope(directory=QUEENS_DIR / qid / "skills", label="queen_ui", priority=2))
# We intentionally don't plumb every colony's project_root into one
# manager — discovery only allows a single project_root. For the
# aggregator we scan every colony's skills/ (and the legacy nested
# .hive/skills/ for pre-flatten colonies) as tagged extra scopes
# instead. That keeps the xml-catalog-per-scope invariant intact
# without requiring N managers.
for cn in colony_names:
extras.append(
ExtraScope(
directory=COLONIES_DIR / cn / "skills",
label="colony_ui",
priority=3,
)
)
extras.append(
ExtraScope(
directory=COLONIES_DIR / cn / ".hive" / "skills",
label="colony_ui",
priority=3,
)
)
# Raw discovery (no override filtering) — we apply per-scope stores
# below when computing ``visible_to``.
discovery = SkillDiscovery(DiscoveryConfig(project_root=None, skip_framework_scope=False, extra_scopes=extras))
discovered = discovery.discover()
# Load all stores once.
queen_stores: dict[str, SkillOverrideStore] = {
qid: SkillOverrideStore.load(QUEENS_DIR / qid / "skills_overrides.json", scope_label=f"queen:{qid}")
for qid in queen_ids
}
colony_stores: dict[str, SkillOverrideStore] = {}
colony_queens: dict[str, str | None] = {}
for cn in colony_names:
colony_stores[cn] = SkillOverrideStore.load(
COLONIES_DIR / cn / "skills_overrides.json", scope_label=f"colony:{cn}"
)
try:
from framework.host.colony_metadata import load_colony_metadata
colony_queens[cn] = load_colony_metadata(cn).get("queen_name") or None
except Exception:
colony_queens[cn] = None
rows: list[dict[str, Any]] = []
# Owner mapping for queen_ui / colony_ui scopes: the dir path encodes
# which queen/colony the skill belongs to.
def _owner_for(skill: ParsedSkill) -> dict[str, str] | None:
base = Path(skill.base_dir)
parts = base.parts
try:
idx_q = parts.index("queens")
if skill.source_scope == "queen_ui" and idx_q + 1 < len(parts):
qid = parts[idx_q + 1]
return {"type": "queen", "id": qid, "name": qid}
except ValueError:
pass
try:
idx_c = parts.index("colonies")
if skill.source_scope == "colony_ui" and idx_c + 1 < len(parts):
cn = parts[idx_c + 1]
return {"type": "colony", "id": cn, "name": cn}
except ValueError:
pass
return None
for skill in sorted(discovered, key=lambda s: s.name):
visible_queens: list[str] = []
visible_colonies: list[str] = []
for qid, qstore in queen_stores.items():
if _effective_enabled(skill, qstore, None):
visible_queens.append(qid)
for cn, cstore in colony_stores.items():
qstore = queen_stores.get(colony_queens.get(cn) or "", None)
if _effective_enabled(skill, qstore, cstore):
visible_colonies.append(cn)
# Provenance: choose the nearest owning store's record if any.
owner = _owner_for(skill)
prov_store: SkillOverrideStore | None = None
if owner and owner["type"] == "queen":
prov_store = queen_stores.get(owner["id"])
elif owner and owner["type"] == "colony":
prov_store = colony_stores.get(owner["id"])
provenance, entry = _resolve_provenance(skill, prov_store, None)
editable = provenance in _EDITABLE_PROVENANCE
rows.append(
{
"name": skill.name,
"description": skill.description,
"source_scope": skill.source_scope,
"provenance": str(provenance),
"owner": owner,
"visible_to": {"queens": sorted(visible_queens), "colonies": sorted(visible_colonies)},
"enabled_by_default": True,
"editable": editable,
"deletable": editable,
"location": skill.location,
"visibility": skill.visibility,
}
)
return web.json_response(
{
"skills": rows,
"queens": [{"id": q, "name": q} for q in queen_ids],
"colonies": [{"name": c, "queen_id": colony_queens.get(c)} for c in colony_names],
}
)
async def handle_get_skill_detail(request: web.Request) -> web.Response:
"""GET /api/skills/{skill_name} — full body for the detail drawer."""
skill_name = request.match_info["skill_name"]
name, err = validate_skill_name(skill_name)
if err or name is None:
return web.json_response({"error": err}, status=400)
manager = _build_admin_manager()
for s in manager.enumerate_skills_with_source():
if s.name == name:
# Re-read body so we get the freshest content (the cached
# ParsedSkill.body was stamped at load time).
try:
body = Path(s.location).read_text(encoding="utf-8")
except OSError as exc:
return web.json_response({"error": f"failed to read {s.location}: {exc}"}, status=500)
return web.json_response(
{
"name": s.name,
"description": s.description,
"source_scope": s.source_scope,
"location": s.location,
"base_dir": s.base_dir,
"body": body,
"visibility": s.visibility,
}
)
return web.json_response({"error": f"skill '{name}' not found"}, status=404)
async def handle_list_scopes(request: web.Request) -> web.Response:
"""GET /api/skills/scopes — enumerate queens and colonies for the UI picker."""
_ensure_queens_known()
queens = []
if QUEENS_DIR.is_dir():
for p in sorted(QUEENS_DIR.glob("*/profile.yaml")):
queens.append({"id": p.parent.name, "name": p.parent.name})
colonies = []
if COLONIES_DIR.is_dir():
for p in sorted(x for x in COLONIES_DIR.iterdir() if x.is_dir()):
queen_id = None
try:
from framework.host.colony_metadata import load_colony_metadata
queen_id = load_colony_metadata(p.name).get("queen_name") or None
except Exception:
pass
colonies.append({"name": p.name, "queen_id": queen_id})
return web.json_response({"queens": queens, "colonies": colonies})
# ---------------------------------------------------------------------------
# Mutations (shared body)
# ---------------------------------------------------------------------------
async def _reload_scope(scope: SkillScope) -> None:
"""Reload the primary manager and every live runtime affected by the scope."""
import asyncio
async def _reload_one(rt) -> None:
try:
await rt.reload_skills()
except Exception:
logger.warning("reload_skills failed for runtime %r", rt, exc_info=True)
# The primary manager (often the queen's) gets reloaded too.
if scope.manager is not None:
async with scope.manager.mutation_lock:
try:
scope.manager.reload()
except Exception:
logger.warning("primary manager reload failed", exc_info=True)
# Runtimes in parallel — each has its own mutation lock.
if scope.affected_runtimes:
await asyncio.gather(*[_reload_one(rt) for rt in scope.affected_runtimes], return_exceptions=True)
async def _handle_create(scope: SkillScope, payload: dict[str, Any], user_id: str) -> web.Response:
draft, err = build_draft(
skill_name=payload.get("name"),
skill_description=payload.get("description"),
skill_body=payload.get("body"),
skill_files=payload.get("files"),
)
if err or draft is None:
return web.json_response({"error": err}, status=400)
replace_existing = bool(payload.get("replace_existing", False))
installed, wrote_err, replaced = write_skill(draft, target_root=scope.write_dir, replace_existing=replace_existing)
if wrote_err is not None or installed is None:
status = 409 if "already exists" in (wrote_err or "") else 500
return web.json_response({"error": wrote_err}, status=status)
enabled = bool(payload.get("enabled", True))
scope.store.upsert(
draft.name,
OverrideEntry(
enabled=enabled,
provenance=Provenance.USER_UI_CREATED,
created_at=utc_now(),
created_by=user_id,
notes=payload.get("notes") if isinstance(payload.get("notes"), str) else None,
),
)
scope.store.save()
await _reload_scope(scope)
return web.json_response(
{
"name": draft.name,
"installed_path": str(installed),
"replaced": replaced,
"enabled": enabled,
"provenance": str(Provenance.USER_UI_CREATED),
},
status=201,
)
async def _handle_patch(scope: SkillScope, skill_name: str, payload: dict[str, Any]) -> web.Response:
name, err = validate_skill_name(skill_name)
if err or name is None:
return web.json_response({"error": err}, status=400)
# A PATCH can't know who originally authored the skill — it only
# stores the user's toggle. Use OTHER rather than stamping a guess
# (FRAMEWORK, USER_UI_CREATED, etc.) that would then outrank the
# source_scope inference on subsequent reads.
existing = scope.store.get(name) or OverrideEntry(provenance=Provenance.OTHER)
if "enabled" in payload:
enabled_raw = payload["enabled"]
if not isinstance(enabled_raw, bool):
return web.json_response({"error": "'enabled' must be a bool"}, status=400)
existing.enabled = enabled_raw
if "param_overrides" in payload:
po = payload["param_overrides"]
if not isinstance(po, dict):
return web.json_response({"error": "'param_overrides' must be an object"}, status=400)
existing.param_overrides = dict(po)
if "notes" in payload:
notes = payload["notes"]
existing.notes = notes if isinstance(notes, str) or notes is None else existing.notes
if "all_defaults_disabled" in payload and isinstance(payload["all_defaults_disabled"], bool):
scope.store.all_defaults_disabled = payload["all_defaults_disabled"]
scope.store.upsert(name, existing)
scope.store.save()
await _reload_scope(scope)
return web.json_response({"name": name, "enabled": existing.enabled, "ok": True})
async def _handle_put_body(scope: SkillScope, skill_name: str, payload: dict[str, Any]) -> web.Response:
name, err = validate_skill_name(skill_name)
if err or name is None:
return web.json_response({"error": err}, status=400)
entry = scope.store.get(name)
provenance = entry.provenance if entry else Provenance.FRAMEWORK
if provenance not in _EDITABLE_PROVENANCE:
return web.json_response({"error": f"skill '{name}' is not editable (provenance={provenance})"}, status=403)
description = payload.get("description")
body = payload.get("body")
if not isinstance(body, str) or not body.strip():
return web.json_response({"error": "'body' is required"}, status=400)
# If the caller omitted description, keep whatever's in the current SKILL.md.
current_desc = None
if description is None:
current_path = scope.write_dir / name / "SKILL.md"
if current_path.exists():
match = re.search(r"^description:\s*(.+)$", current_path.read_text(encoding="utf-8"), re.MULTILINE)
if match:
current_desc = match.group(1).strip().strip("\"'")
final_desc = description if isinstance(description, str) else (current_desc or "")
draft, derr = build_draft(
skill_name=name,
skill_description=final_desc,
skill_body=body,
skill_files=payload.get("files"),
)
if derr or draft is None:
return web.json_response({"error": derr}, status=400)
installed, werr, _ = write_skill(draft, target_root=scope.write_dir, replace_existing=True)
if werr or installed is None:
return web.json_response({"error": werr}, status=500)
# Touch created_at? No — keep the original author. Just refresh notes/overrides.
await _reload_scope(scope)
return web.json_response({"name": name, "installed_path": str(installed)})
async def _handle_delete(scope: SkillScope, skill_name: str) -> web.Response:
name, err = validate_skill_name(skill_name)
if err or name is None:
return web.json_response({"error": err}, status=400)
entry = scope.store.get(name)
provenance = entry.provenance if entry else None
if provenance is not None and provenance not in _EDITABLE_PROVENANCE:
return web.json_response({"error": f"skill '{name}' is not deletable (provenance={provenance})"}, status=403)
removed, rerr = authoring_remove_skill(scope.write_dir, name)
if rerr:
return web.json_response({"error": rerr}, status=500)
scope.store.remove(name, tombstone=True)
scope.store.save()
await _reload_scope(scope)
return web.json_response({"name": name, "removed": removed})
async def _handle_reload(scope: SkillScope) -> web.Response:
await _reload_scope(scope)
return web.json_response({"ok": True, "target": scope.target_id, "kind": scope.kind})
# ---------------------------------------------------------------------------
# HTTP handlers (thin wrappers that resolve the scope then delegate)
# ---------------------------------------------------------------------------
def _requester_id(request: web.Request) -> str:
# The UI surfaces a user email via the shell's CLAUDE.md; production
# deployments should replace this with authenticated session state.
return request.headers.get("X-User", "ui")
async def handle_create_queen_skill(request: web.Request) -> web.Response:
manager = request.app.get("manager")
scope = _queen_scope(manager, request.match_info["queen_id"])
if scope is None:
return web.json_response({"error": "queen not found"}, status=404)
try:
payload = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON body"}, status=400)
return await _handle_create(scope, payload, _requester_id(request))
async def handle_patch_queen_skill(request: web.Request) -> web.Response:
manager = request.app.get("manager")
scope = _queen_scope(manager, request.match_info["queen_id"])
if scope is None:
return web.json_response({"error": "queen not found"}, status=404)
try:
payload = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON body"}, status=400)
return await _handle_patch(scope, request.match_info["skill_name"], payload)
async def handle_put_queen_skill_body(request: web.Request) -> web.Response:
manager = request.app.get("manager")
scope = _queen_scope(manager, request.match_info["queen_id"])
if scope is None:
return web.json_response({"error": "queen not found"}, status=404)
try:
payload = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON body"}, status=400)
return await _handle_put_body(scope, request.match_info["skill_name"], payload)
async def handle_delete_queen_skill(request: web.Request) -> web.Response:
manager = request.app.get("manager")
scope = _queen_scope(manager, request.match_info["queen_id"])
if scope is None:
return web.json_response({"error": "queen not found"}, status=404)
return await _handle_delete(scope, request.match_info["skill_name"])
async def handle_reload_queen_skills(request: web.Request) -> web.Response:
manager = request.app.get("manager")
scope = _queen_scope(manager, request.match_info["queen_id"])
if scope is None:
return web.json_response({"error": "queen not found"}, status=404)
return await _handle_reload(scope)
async def handle_create_colony_skill(request: web.Request) -> web.Response:
manager = request.app.get("manager")
scope = _colony_scope(manager, request.match_info["colony_name"])
if scope is None:
return web.json_response({"error": "colony not found"}, status=404)
try:
payload = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON body"}, status=400)
return await _handle_create(scope, payload, _requester_id(request))
async def handle_patch_colony_skill(request: web.Request) -> web.Response:
manager = request.app.get("manager")
scope = _colony_scope(manager, request.match_info["colony_name"])
if scope is None:
return web.json_response({"error": "colony not found"}, status=404)
try:
payload = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON body"}, status=400)
return await _handle_patch(scope, request.match_info["skill_name"], payload)
async def handle_put_colony_skill_body(request: web.Request) -> web.Response:
manager = request.app.get("manager")
scope = _colony_scope(manager, request.match_info["colony_name"])
if scope is None:
return web.json_response({"error": "colony not found"}, status=404)
try:
payload = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON body"}, status=400)
return await _handle_put_body(scope, request.match_info["skill_name"], payload)
async def handle_delete_colony_skill(request: web.Request) -> web.Response:
manager = request.app.get("manager")
scope = _colony_scope(manager, request.match_info["colony_name"])
if scope is None:
return web.json_response({"error": "colony not found"}, status=404)
return await _handle_delete(scope, request.match_info["skill_name"])
async def handle_reload_colony_skills(request: web.Request) -> web.Response:
manager = request.app.get("manager")
scope = _colony_scope(manager, request.match_info["colony_name"])
if scope is None:
return web.json_response({"error": "colony not found"}, status=404)
return await _handle_reload(scope)
# ---------------------------------------------------------------------------
# Upload handler (multipart: SKILL.md OR .zip)
# ---------------------------------------------------------------------------
async def handle_upload_skill(request: web.Request) -> web.Response:
"""POST /api/skills/upload — accept a single SKILL.md or a .zip bundle.
Form fields:
``file`` (required) — the .md / .zip payload
``scope`` (required) — "user" | "queen" | "colony"
``target_id`` (queen|colony) — queen_id or colony_name for those scopes
``enabled`` (optional) — "true"/"false" (defaults true)
``replace_existing`` — "true"/"false" (defaults false)
``name`` — optional override for single-.md uploads
"""
manager = request.app.get("manager")
if not request.content_type.startswith("multipart/"):
return web.json_response({"error": "expected multipart/form-data"}, status=400)
reader = await request.multipart()
upload_bytes: bytes | None = None
upload_filename: str | None = None
form: dict[str, str] = {}
while True:
part = await reader.next()
if part is None:
break
if part.name == "file":
buf = io.BytesIO()
while True:
chunk = await part.read_chunk(size=65536)
if not chunk:
break
buf.write(chunk)
if buf.tell() > _MAX_UPLOAD_BYTES:
return web.json_response({"error": f"upload exceeds {_MAX_UPLOAD_BYTES} bytes"}, status=413)
upload_bytes = buf.getvalue()
upload_filename = part.filename or ""
else:
form[part.name or ""] = (await part.text()).strip()
if upload_bytes is None:
return web.json_response({"error": "missing 'file' part"}, status=400)
scope_kind = form.get("scope", "").lower()
if scope_kind not in {"user", "queen", "colony"}:
return web.json_response({"error": "scope must be user|queen|colony"}, status=400)
target_id = form.get("target_id", "").strip()
if scope_kind in {"queen", "colony"} and not target_id:
return web.json_response({"error": f"target_id required for scope={scope_kind}"}, status=400)
enabled = form.get("enabled", "true").lower() != "false"
replace_existing = form.get("replace_existing", "false").lower() == "true"
name_override = form.get("name", "").strip() or None
# Resolve the write target
if scope_kind == "user":
write_dir = Path.home() / ".hive" / "skills"
overrides_path: Path | None = None
store: SkillOverrideStore | None = None
affected_runtimes: list = []
elif scope_kind == "queen":
scope = _queen_scope(manager, target_id)
if scope is None:
return web.json_response({"error": "queen not found"}, status=404)
write_dir = scope.write_dir
overrides_path = scope.overrides_path
store = scope.store
affected_runtimes = scope.affected_runtimes
else: # colony
scope = _colony_scope(manager, target_id)
if scope is None:
return web.json_response({"error": "colony not found"}, status=404)
write_dir = scope.write_dir
overrides_path = scope.overrides_path
store = scope.store
affected_runtimes = scope.affected_runtimes
# Extract into a draft
draft: Any
if upload_bytes.startswith(_ZIP_MAGIC) or (upload_filename or "").endswith(".zip"):
draft_name, draft_desc, draft_body, draft_files, err = _extract_from_zip(upload_bytes, name_hint=name_override)
if err:
return web.json_response({"error": err}, status=400)
else:
draft_name, draft_desc, draft_body, draft_files, err = _extract_from_md(
upload_bytes, filename=upload_filename, name_hint=name_override
)
if err:
return web.json_response({"error": err}, status=400)
draft, derr = build_draft(
skill_name=draft_name,
skill_description=draft_desc,
skill_body=draft_body,
skill_files=draft_files,
)
if derr or draft is None:
return web.json_response({"error": derr}, status=400)
installed, werr, replaced = write_skill(draft, target_root=write_dir, replace_existing=replace_existing)
if werr or installed is None:
status = 409 if "already exists" in (werr or "") else 500
return web.json_response({"error": werr}, status=status)
if store is not None and overrides_path is not None:
store.upsert(
draft.name,
OverrideEntry(
enabled=enabled,
provenance=Provenance.USER_UI_CREATED,
created_at=utc_now(),
created_by=_requester_id(request),
),
)
store.save()
# Reload affected runtimes (queen/colony scopes only). For user
# scope, changes take effect the next time a runtime reloads —
# usually on the next queen boot.
for rt in affected_runtimes:
try:
await rt.reload_skills()
except Exception:
logger.warning("runtime reload after upload failed", exc_info=True)
return web.json_response(
{
"name": draft.name,
"installed_path": str(installed),
"replaced": replaced,
"scope": scope_kind,
"target_id": target_id or None,
"enabled": enabled,
},
status=201,
)
def _extract_from_md(
raw: bytes,
*,
filename: str | None,
name_hint: str | None,
) -> tuple[str, str, str, list[dict], str | None]:
"""Parse a lone SKILL.md upload. Returns (name, desc, body, files, error)."""
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
return "", "", "", [], "upload is not valid UTF-8"
name, desc, body, err = _parse_skill_md_text(text)
if err:
return "", "", "", [], err
if name_hint:
name = name_hint
if not name and filename:
stem = Path(filename).stem
if stem and stem.lower() != "skill":
name = stem.lower()
if not name:
return "", "", "", [], "could not determine skill name (pass 'name' form field)"
return name, desc, body, [], None
def _extract_from_zip(
raw: bytes,
*,
name_hint: str | None,
) -> tuple[str, str, str, list[dict], str | None]:
"""Parse a .zip bundle. SKILL.md must be at root (not inside a folder)."""
try:
z = zipfile.ZipFile(io.BytesIO(raw))
except zipfile.BadZipFile:
return "", "", "", [], "invalid zip file"
names = z.namelist()
skill_mds = [n for n in names if n == "SKILL.md" or n.endswith("/SKILL.md")]
if not skill_mds:
return "", "", "", [], "zip must contain SKILL.md at root"
# Prefer the shallowest SKILL.md; multiple is usually an authoring mistake
skill_mds.sort(key=lambda p: p.count("/"))
root_skill_md = skill_mds[0]
root_prefix = root_skill_md[: -len("SKILL.md")] if root_skill_md != "SKILL.md" else ""
skill_text = z.read(root_skill_md).decode("utf-8", errors="replace")
name, desc, body, err = _parse_skill_md_text(skill_text)
if err:
return "", "", "", [], err
if name_hint:
name = name_hint
if not name and root_prefix:
name = root_prefix.strip("/")
if not name:
return "", "", "", [], "could not determine skill name from zip"
files: list[dict] = []
for entry_name in names:
if entry_name == root_skill_md or entry_name.endswith("/"):
continue
if not entry_name.startswith(root_prefix):
continue
rel = entry_name[len(root_prefix) :]
if not rel or rel == "SKILL.md":
continue
content_bytes = z.read(entry_name)
try:
content = content_bytes.decode("utf-8")
except UnicodeDecodeError:
return "", "", "", [], f"binary file '{rel}' not supported yet"
files.append({"path": rel, "content": content})
return name, desc, body, files, None
def _parse_skill_md_text(text: str) -> tuple[str, str, str, str | None]:
"""Lightweight frontmatter split so uploads can be validated offline.
Full parsing happens inside :mod:`framework.skills.parser` when
discovery runs; this only needs ``name``, ``description`` and the
body so we can hand off to ``build_draft``.
"""
if not text.startswith("---"):
return "", "", "", "SKILL.md must start with '---' frontmatter"
try:
_, fm, body = text.split("---", 2)
except ValueError:
return "", "", "", "SKILL.md frontmatter is malformed"
name = ""
description = ""
for line in fm.splitlines():
line = line.strip()
if line.startswith("name:"):
name = line.split(":", 1)[1].strip().strip("\"'")
elif line.startswith("description:"):
description = line.split(":", 1)[1].strip().strip("\"'")
return name, description, body.lstrip("\n"), None
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def register_routes(app: web.Application) -> None:
r = app.router
# Per-queen
r.add_get("/api/queen/{queen_id}/skills", handle_list_queen_skills)
r.add_post("/api/queen/{queen_id}/skills", handle_create_queen_skill)
r.add_patch("/api/queen/{queen_id}/skills/{skill_name}", handle_patch_queen_skill)
r.add_put("/api/queen/{queen_id}/skills/{skill_name}/body", handle_put_queen_skill_body)
r.add_delete("/api/queen/{queen_id}/skills/{skill_name}", handle_delete_queen_skill)
r.add_post("/api/queen/{queen_id}/skills/reload", handle_reload_queen_skills)
# Per-colony
r.add_get("/api/colonies/{colony_name}/skills", handle_list_colony_skills)
r.add_post("/api/colonies/{colony_name}/skills", handle_create_colony_skill)
r.add_patch("/api/colonies/{colony_name}/skills/{skill_name}", handle_patch_colony_skill)
r.add_put(
"/api/colonies/{colony_name}/skills/{skill_name}/body",
handle_put_colony_skill_body,
)
r.add_delete("/api/colonies/{colony_name}/skills/{skill_name}", handle_delete_colony_skill)
r.add_post("/api/colonies/{colony_name}/skills/reload", handle_reload_colony_skills)
# Aggregated library (powers the Skills Library page)
r.add_get("/api/skills", handle_list_all_skills)
r.add_get("/api/skills/scopes", handle_list_scopes)
r.add_post("/api/skills/upload", handle_upload_skill)
r.add_get("/api/skills/{skill_name}", handle_get_skill_detail)