Files
devplacepy/devplacepy/database/skills.py
T
retoorandClaude Sonnet 5.5 f55c7ee5f4
DevPlace CI / test (push) Failing after 31m42s
Add community node protocol spec, Agent Skills, and make the failing tests hermetic
Community nodes (Phase 1 of plan.md): the protocol specification in
services/nodes/spec.py as the single source of truth, GET /nodes/spec.json,
a generated protocol docs page, the Community nodes API docs group, and the
implementation plan plus the residential proxy research. The no-open-proxy rule
is enforced in the spec (open_proxy cannot be set).

Agent Skills: register the Skills API docs group, cascade skills on account
deletion, add the nested CLAUDE.md, README and routes docs, and cover the
parser, the fetcher (end to end against a mock origin), the data layer and the
routes with 73 unit and 16 api tests.

Tests made hermetic with the repo's existing patterns instead of ambient
environment: embeddings tests set OPENROUTER_API_KEY via monkeypatch, the
workspace tests install FakeBackend via runtime.set_backend, and the skills
install tests use run_async. DockerCliBackend now returns 127 (command not
found) when the CLI is missing instead of raising FileNotFoundError, so a host
without docker answers with a clean error rather than a 500. The Providers tab
empty state no longer names the Model routes tab.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 07:13:25 +02:00

142 lines
4.2 KiB
Python

# retoor <retoor@molodetz.nl>
import json
from sqlalchemy.exc import IntegrityError
from devplacepy.services.skills import limits
from .core import _now_iso, db, get_table
from .soft_delete import soft_delete
class SkillLimitError(ValueError):
pass
def count_skills(user_uid):
if "skills" not in db.tables:
return 0
rows = db.query(
"SELECT COUNT(*) AS c FROM skills WHERE user_uid=:u AND deleted_at IS NULL",
u=user_uid,
)
return next(iter(rows))["c"]
def list_skills(user_uid):
if "skills" not in db.tables:
return []
return list(
db.query(
"SELECT * FROM skills WHERE user_uid=:u AND deleted_at IS NULL ORDER BY name",
u=user_uid,
)
)
def get_skill_row(user_uid, name):
if "skills" not in db.tables:
return None
rows = db.query(
"SELECT * FROM skills WHERE user_uid=:u AND name=:n AND deleted_at IS NULL",
u=user_uid,
n=name,
)
return next(iter(rows), None)
def enabled_skill_summaries(user_uid):
if "skills" not in db.tables:
return []
rows = db.query(
"SELECT name, description FROM skills "
"WHERE user_uid=:u AND enabled=1 AND deleted_at IS NULL ORDER BY name LIMIT :n",
u=user_uid,
n=limits.PROMPT_MAX_SKILLS,
)
return [(row["name"], row["description"]) for row in rows]
def upsert_skill(user_uid, fields):
table = get_table("skills")
name = fields["name"]
existing = table.find_one(user_uid=user_uid, name=name)
now = _now_iso()
values = {**fields, "updated_at": now, "fetched_at": now}
if existing is None:
if count_skills(user_uid) >= limits.SKILLS_PER_USER_MAX:
raise SkillLimitError(
f"You can have at most {limits.SKILLS_PER_USER_MAX} skills; delete one first."
)
from devplacepy.utils import generate_uid
row = {
**values,
"uid": generate_uid(),
"user_uid": user_uid,
"enabled": 1,
"created_at": now,
"deleted_at": None,
"deleted_by": None,
}
try:
table.insert(row)
return get_skill_row(user_uid, name), "install"
except IntegrityError:
existing = table.find_one(user_uid=user_uid, name=name)
revived = existing.get("deleted_at") is not None
if revived and count_skills(user_uid) >= limits.SKILLS_PER_USER_MAX:
raise SkillLimitError(
f"You can have at most {limits.SKILLS_PER_USER_MAX} skills; delete one first."
)
update = {"id": existing["id"], **values, "deleted_at": None, "deleted_by": None}
if revived:
update["enabled"] = 1
table.update(update, ["id"])
return get_skill_row(user_uid, name), "update"
def set_skill_enabled(user_uid, name, enabled):
row = get_skill_row(user_uid, name)
if row is None:
return None
get_table("skills").update(
{"id": row["id"], "enabled": 1 if enabled else 0, "updated_at": _now_iso()},
["id"],
)
return get_skill_row(user_uid, name)
def delete_skill(user_uid, name, deleted_by):
row = get_skill_row(user_uid, name)
if row is None:
return False
soft_delete("skills", deleted_by, uid=row["uid"])
return True
def skill_view(row, *, detail=False):
view = {
"uid": row["uid"],
"name": row["name"],
"description": row["description"],
"enabled": bool(row.get("enabled")),
"source_url": row.get("source_url") or "",
"license": row.get("license") or "",
"compatibility": row.get("compatibility") or "",
"allowed_tools": row.get("allowed_tools") or "",
"sha256": row.get("sha256") or "",
"file_count": len(json.loads(row.get("files") or "{}")),
"fetched_at": row.get("fetched_at"),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
if detail:
view["metadata"] = json.loads(row.get("metadata") or "{}")
view["body"] = row.get("body") or ""
view["files"] = json.loads(row.get("files") or "{}")
view["skipped"] = json.loads(row.get("skipped") or "[]")
view["warnings"] = json.loads(row.get("warnings") or "[]")
return view