"""Skills and protocols — the catalog behind the agent's methodologies.
Reads are open to every authenticated user for shared entries and to the owner
for personal ones. Writes follow the ownership rule: a user owns their personal
entries; shared entries exist only by an admin's hand.
The document format (YAML front matter + markdown body) is described in
``navi.skills.models``; the API stores it verbatim and lets the client edit it.
"""
from typing import Annotated, Literal
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from navi.api.deps import get_skill_store
from navi.auth import User
from navi.auth.deps import require_user
from navi.skills import AREAS, KINDS, SkillEntry, SkillError, SkillStore
log = structlog.get_logger()
router = APIRouter(prefix="/skills", tags=["skills"])
Visibility = Literal["personal", "shared"]
Kind = Literal["skill", "protocol"]
def _may_read(entry: SkillEntry, user: User) -> bool:
return entry.visibility == "shared" or entry.owner_id == user.id
def _may_write(entry: SkillEntry, user: User) -> bool:
if entry.owner_id == user.id and entry.visibility == "personal":
return True
return entry.visibility == "shared" and user.role == "admin"
def _payload(entry: SkillEntry, user: User, *, include_content: bool = True) -> dict:
data = entry.to_dict(include_content=include_content)
data["can_edit"] = _may_write(entry, user)
return data
class CreateSkillRequest(BaseModel):
content: str = Field(description="Markdown document with a YAML front matter block.")
visibility: Visibility = "personal"
reason: str | None = Field(default=None, max_length=1000)
class UpdateSkillRequest(BaseModel):
content: str
reason: str | None = Field(default=None, max_length=1000)
# Only an admin may move an entry between personal and shared.
visibility: Visibility | None = None
@router.get("")
async def list_skills(
user: Annotated[User, Depends(require_user)],
store: Annotated[SkillStore, Depends(get_skill_store)],
kind: Annotated[Kind | None, Query()] = None,
q: Annotated[str | None, Query(max_length=200)] = None,
tag: Annotated[str | None, Query(max_length=100)] = None,
area: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
) -> dict:
entries = await store.list(
user_id=user.id, kind=kind, query=q, tag=tag, area=area, limit=limit,
)
return {
# Documents can be long; the list carries the catalog card only and the
# full text is fetched when one is opened.
"items": [_payload(e, user, include_content=False) for e in entries],
"areas": list(AREAS),
"kinds": list(KINDS),
}
@router.get("/proposals/{proposal_id}")
async def get_proposal(
proposal_id: str,
user: Annotated[User, Depends(require_user)],
store: Annotated[SkillStore, Depends(get_skill_store)],
) -> dict:
"""A pending (or decided) protocol proposal, for the chat card to render.
Declared before ``/{skill_id}`` — FastAPI matches routes in order, and the
other path would swallow ``proposals`` as an entry id.
"""
proposal = await store.get_proposal(proposal_id)
if proposal is None or (proposal.owner_id != user.id and user.role != "admin"):
raise HTTPException(status_code=404, detail="Proposal not found")
return proposal.to_dict()
@router.get("/{skill_id}")
async def get_skill(
skill_id: str,
user: Annotated[User, Depends(require_user)],
store: Annotated[SkillStore, Depends(get_skill_store)],
) -> dict:
entry = await store.get(skill_id)
if entry is None or not _may_read(entry, user):
raise HTTPException(status_code=404, detail="Skill not found")
return _payload(entry, user)
@router.post("", status_code=201)
async def create_skill(
payload: CreateSkillRequest,
user: Annotated[User, Depends(require_user)],
store: Annotated[SkillStore, Depends(get_skill_store)],
) -> dict:
if payload.visibility == "shared" and user.role != "admin":
raise HTTPException(
status_code=403,
detail="Only an admin can create shared skills and protocols",
)
try:
entry = await store.create(
owner_id=user.id,
content=payload.content,
visibility=payload.visibility,
edited_by=user.id,
reason=payload.reason,
)
except SkillError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return _payload(entry, user)
@router.put("/{skill_id}")
async def update_skill(
skill_id: str,
payload: UpdateSkillRequest,
user: Annotated[User, Depends(require_user)],
store: Annotated[SkillStore, Depends(get_skill_store)],
) -> dict:
entry = await store.get(skill_id)
if entry is None or not _may_read(entry, user):
raise HTTPException(status_code=404, detail="Skill not found")
if not _may_write(entry, user):
raise HTTPException(status_code=403, detail="You cannot edit this entry")
if payload.visibility is not None and payload.visibility != entry.visibility:
if user.role != "admin":
raise HTTPException(status_code=403, detail="Only an admin can change visibility")
try:
entry = await store.set_visibility(
skill_id, payload.visibility, edited_by=user.id,
) or entry
except SkillError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
try:
updated = await store.update(
skill_id, content=payload.content, edited_by=user.id, reason=payload.reason,
)
except SkillError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
if updated is None:
raise HTTPException(status_code=404, detail="Skill not found")
return _payload(updated, user)
@router.delete("/{skill_id}", status_code=204)
async def delete_skill(
skill_id: str,
user: Annotated[User, Depends(require_user)],
store: Annotated[SkillStore, Depends(get_skill_store)],
) -> None:
entry = await store.get(skill_id)
if entry is None or not _may_read(entry, user):
raise HTTPException(status_code=404, detail="Skill not found")
if not _may_write(entry, user):
raise HTTPException(status_code=403, detail="You cannot delete this entry")
if not await store.delete(skill_id):
raise HTTPException(status_code=404, detail="Skill not found")
log.info("skills.deleted", skill_id=skill_id, user_id=user.id)
@router.get("/{skill_id}/versions")
async def list_versions(
skill_id: str,
user: Annotated[User, Depends(require_user)],
store: Annotated[SkillStore, Depends(get_skill_store)],
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> dict:
entry = await store.get(skill_id)
if entry is None or not _may_read(entry, user):
raise HTTPException(status_code=404, detail="Skill not found")
return {"items": await store.list_versions(skill_id, limit=limit)}