feat: add skill pack management features including creation, editing, and syncing

This commit is contained in:
Abhimanyu Saharan
2026-02-14 02:05:11 +05:30
committed by Abhimanyu Saharan
parent 88565f4d69
commit a7e1e5cbf4
28 changed files with 4403 additions and 430 deletions

View File

@@ -1,7 +1,12 @@
"""Skills marketplace API for catalog management and gateway install actions."""
"""Skills marketplace and skill pack APIs."""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING
from urllib.parse import unquote, urlparse
from uuid import UUID
@@ -15,12 +20,16 @@ from app.db.session import get_session
from app.models.gateway_installed_skills import GatewayInstalledSkill
from app.models.gateways import Gateway
from app.models.marketplace_skills import MarketplaceSkill
from app.models.skill_packs import SkillPack
from app.schemas.common import OkResponse
from app.schemas.skills_marketplace import (
MarketplaceSkillActionResponse,
MarketplaceSkillCardRead,
MarketplaceSkillCreate,
MarketplaceSkillRead,
SkillPackCreate,
SkillPackRead,
SkillPackSyncResponse,
)
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
from app.services.openclaw.gateway_resolver import gateway_client_config, require_gateway_workspace_root
@@ -37,6 +46,18 @@ ORG_ADMIN_DEP = Depends(require_org_admin)
GATEWAY_ID_QUERY = Query(...)
@dataclass(frozen=True)
class PackSkillCandidate:
"""Single skill discovered in a pack repository."""
name: str
description: str | None
source_url: str
category: str | None = None
risk: str | None = None
source: str | None = None
def _skills_install_dir(workspace_root: str) -> str:
normalized = workspace_root.rstrip("/\\")
if not normalized:
@@ -54,6 +75,293 @@ def _infer_skill_name(source_url: str) -> str:
return "Skill"
def _infer_skill_description(skill_file: Path) -> str | None:
try:
content = skill_file.read_text(encoding="utf-8", errors="ignore")
except OSError:
return None
lines = [line.strip() for line in content.splitlines()]
if not lines:
return None
in_frontmatter = False
for line in lines:
if line == "---":
in_frontmatter = not in_frontmatter
continue
if in_frontmatter:
if line.lower().startswith("description:"):
value = line.split(":", maxsplit=1)[-1].strip().strip('"\'')
return value or None
continue
if not line or line.startswith("#"):
continue
return line
return None
def _infer_skill_display_name(skill_file: Path, fallback: str) -> str:
try:
content = skill_file.read_text(encoding="utf-8", errors="ignore")
except OSError:
content = ""
in_frontmatter = False
for raw_line in content.splitlines():
line = raw_line.strip()
if line == "---":
in_frontmatter = not in_frontmatter
continue
if in_frontmatter and line.lower().startswith("name:"):
value = line.split(":", maxsplit=1)[-1].strip().strip('"\'')
if value:
return value
for raw_line in content.splitlines():
line = raw_line.strip()
if line.startswith("#"):
heading = line.lstrip("#").strip()
if heading:
return heading
normalized_fallback = fallback.replace("-", " ").replace("_", " ").strip()
return normalized_fallback or "Skill"
def _normalize_repo_source_url(source_url: str) -> str:
normalized = source_url.strip().rstrip("/")
if normalized.endswith(".git"):
return normalized[: -len(".git")]
return normalized
def _to_tree_source_url(repo_source_url: str, branch: str, rel_path: str) -> str:
repo_url = _normalize_repo_source_url(repo_source_url)
safe_branch = branch.strip() or "main"
rel = rel_path.strip().lstrip("/")
if not rel:
return f"{repo_url}/tree/{safe_branch}"
return f"{repo_url}/tree/{safe_branch}/{rel}"
def _repo_base_from_tree_source_url(source_url: str) -> str | None:
parsed = urlparse(source_url)
marker = "/tree/"
marker_index = parsed.path.find(marker)
if marker_index <= 0:
return None
repo_path = parsed.path[:marker_index]
if not repo_path:
return None
return _normalize_repo_source_url(f"{parsed.scheme}://{parsed.netloc}{repo_path}")
def _build_skill_count_by_repo(skills: list[MarketplaceSkill]) -> dict[str, int]:
counts: dict[str, int] = {}
for skill in skills:
repo_base = _repo_base_from_tree_source_url(skill.source_url)
if repo_base is None:
continue
counts[repo_base] = counts.get(repo_base, 0) + 1
return counts
def _normalize_repo_path(path_value: str) -> str:
cleaned = path_value.strip().replace("\\", "/")
while cleaned.startswith("./"):
cleaned = cleaned[2:]
cleaned = cleaned.lstrip("/").rstrip("/")
lowered = cleaned.lower()
if lowered.endswith("/skill.md"):
cleaned = cleaned.rsplit("/", maxsplit=1)[0]
elif lowered == "skill.md":
cleaned = ""
return cleaned
def _coerce_index_entries(payload: object) -> list[dict[str, object]]:
if isinstance(payload, list):
return [entry for entry in payload if isinstance(entry, dict)]
if isinstance(payload, dict):
entries = payload.get("skills")
if isinstance(entries, list):
return [entry for entry in entries if isinstance(entry, dict)]
return []
def _collect_pack_skills_from_index(
*,
repo_dir: Path,
source_url: str,
branch: str,
) -> list[PackSkillCandidate] | None:
index_file = repo_dir / "skills_index.json"
if not index_file.is_file():
return None
try:
payload = json.loads(index_file.read_text(encoding="utf-8"))
except OSError as exc:
raise RuntimeError("unable to read skills_index.json") from exc
except json.JSONDecodeError as exc:
raise RuntimeError("skills_index.json is not valid JSON") from exc
found: dict[str, PackSkillCandidate] = {}
for entry in _coerce_index_entries(payload):
indexed_path = entry.get("path")
has_indexed_path = False
rel_path = ""
if isinstance(indexed_path, str) and indexed_path.strip():
has_indexed_path = True
rel_path = _normalize_repo_path(indexed_path)
indexed_source = entry.get("source_url")
candidate_source_url: str | None = None
if isinstance(indexed_source, str) and indexed_source.strip():
source_candidate = indexed_source.strip()
if source_candidate.startswith(("https://", "http://")):
candidate_source_url = source_candidate
else:
indexed_rel = _normalize_repo_path(source_candidate)
if indexed_rel:
candidate_source_url = _to_tree_source_url(source_url, branch, indexed_rel)
elif has_indexed_path:
candidate_source_url = _to_tree_source_url(source_url, branch, rel_path)
if not candidate_source_url:
continue
indexed_name = entry.get("name")
if isinstance(indexed_name, str) and indexed_name.strip():
name = indexed_name.strip()
else:
fallback = Path(rel_path).name if rel_path else "Skill"
name = _infer_skill_name(fallback)
indexed_description = entry.get("description")
description = (
indexed_description.strip()
if isinstance(indexed_description, str) and indexed_description.strip()
else None
)
indexed_category = entry.get("category")
category = (
indexed_category.strip()
if isinstance(indexed_category, str) and indexed_category.strip()
else None
)
indexed_risk = entry.get("risk")
risk = (
indexed_risk.strip()
if isinstance(indexed_risk, str) and indexed_risk.strip()
else None
)
indexed_source_label = entry.get("source")
source_label = (
indexed_source_label.strip()
if isinstance(indexed_source_label, str) and indexed_source_label.strip()
else None
)
found[candidate_source_url] = PackSkillCandidate(
name=name,
description=description,
source_url=candidate_source_url,
category=category,
risk=risk,
source=source_label,
)
return list(found.values())
def _collect_pack_skills_from_repo(
*,
repo_dir: Path,
source_url: str,
branch: str,
) -> list[PackSkillCandidate]:
indexed = _collect_pack_skills_from_index(
repo_dir=repo_dir,
source_url=source_url,
branch=branch,
)
if indexed is not None:
return indexed
found: dict[str, PackSkillCandidate] = {}
for skill_file in sorted(repo_dir.rglob("SKILL.md")):
rel_file_parts = skill_file.relative_to(repo_dir).parts
# Skip hidden folders like .git, .github, etc.
if any(part.startswith(".") for part in rel_file_parts):
continue
skill_dir = skill_file.parent
rel_dir = (
""
if skill_dir == repo_dir
else skill_dir.relative_to(repo_dir).as_posix()
)
fallback_name = (
_infer_skill_name(source_url) if skill_dir == repo_dir else skill_dir.name
)
name = _infer_skill_display_name(skill_file, fallback=fallback_name)
description = _infer_skill_description(skill_file)
tree_url = _to_tree_source_url(source_url, branch, rel_dir)
found[tree_url] = PackSkillCandidate(
name=name,
description=description,
source_url=tree_url,
)
if found:
return list(found.values())
return []
def _collect_pack_skills(source_url: str) -> list[PackSkillCandidate]:
"""Clone a pack repository and collect skills from index or `skills/**/SKILL.md`."""
with TemporaryDirectory(prefix="skill-pack-sync-") as tmp_dir:
repo_dir = Path(tmp_dir)
try:
subprocess.run(
["git", "clone", "--depth", "1", source_url, str(repo_dir)],
check=True,
capture_output=True,
text=True,
)
except FileNotFoundError as exc:
raise RuntimeError("git binary not available on the server") from exc
except subprocess.CalledProcessError as exc:
stderr = (exc.stderr or "").strip()
detail = stderr or "unable to clone pack repository"
raise RuntimeError(detail) from exc
try:
branch = subprocess.run(
["git", "-C", str(repo_dir), "rev-parse", "--abbrev-ref", "HEAD"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
except (FileNotFoundError, subprocess.CalledProcessError):
branch = "main"
return _collect_pack_skills_from_repo(
repo_dir=repo_dir,
source_url=source_url,
branch=branch,
)
def _install_instruction(*, skill: MarketplaceSkill, gateway: Gateway) -> str:
install_dir = _skills_install_dir(gateway.workspace_root)
return (
@@ -93,6 +401,9 @@ def _as_card(
organization_id=skill.organization_id,
name=skill.name,
description=skill.description,
category=skill.category,
risk=skill.risk,
source=skill.source,
source_url=skill.source_url,
created_at=skill.created_at,
updated_at=skill.updated_at,
@@ -101,6 +412,24 @@ def _as_card(
)
def _as_skill_pack_read(pack: SkillPack) -> SkillPackRead:
return SkillPackRead(
id=pack.id,
organization_id=pack.organization_id,
name=pack.name,
description=pack.description,
source_url=pack.source_url,
skill_count=0,
created_at=pack.created_at,
updated_at=pack.updated_at,
)
def _pack_skill_count(*, pack: SkillPack, count_by_repo: dict[str, int]) -> int:
repo_base = _normalize_repo_source_url(pack.source_url)
return count_by_repo.get(repo_base, 0)
async def _require_gateway_for_org(
*,
gateway_id: UUID,
@@ -131,6 +460,21 @@ async def _require_marketplace_skill_for_org(
return skill
async def _require_skill_pack_for_org(
*,
pack_id: UUID,
session: AsyncSession,
ctx: OrganizationContext,
) -> SkillPack:
pack = await SkillPack.objects.by_id(pack_id).first(session)
if pack is None or pack.organization_id != ctx.organization.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Skill pack not found",
)
return pack
async def _dispatch_gateway_instruction(
*,
session: AsyncSession,
@@ -176,7 +520,7 @@ async def create_marketplace_skill(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> MarketplaceSkill:
"""Register a skill source URL in the organization's marketplace catalog."""
"""Register or update a direct marketplace skill URL in the catalog."""
source_url = str(payload.source_url).strip()
existing = await MarketplaceSkill.objects.filter_by(
organization_id=ctx.organization.id,
@@ -311,3 +655,211 @@ async def uninstall_marketplace_skill(
gateway_id=gateway.id,
installed=False,
)
@router.get("/packs", response_model=list[SkillPackRead])
async def list_skill_packs(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> list[SkillPackRead]:
"""List skill packs configured for the organization."""
packs = (
await SkillPack.objects.filter_by(organization_id=ctx.organization.id)
.order_by(col(SkillPack.created_at).desc())
.all(session)
)
marketplace_skills = await MarketplaceSkill.objects.filter_by(
organization_id=ctx.organization.id,
).all(session)
count_by_repo = _build_skill_count_by_repo(marketplace_skills)
return [
_as_skill_pack_read(pack).model_copy(
update={"skill_count": _pack_skill_count(pack=pack, count_by_repo=count_by_repo)},
)
for pack in packs
]
@router.get("/packs/{pack_id}", response_model=SkillPackRead)
async def get_skill_pack(
pack_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> SkillPackRead:
"""Get one skill pack by ID."""
pack = await _require_skill_pack_for_org(pack_id=pack_id, session=session, ctx=ctx)
marketplace_skills = await MarketplaceSkill.objects.filter_by(
organization_id=ctx.organization.id,
).all(session)
count_by_repo = _build_skill_count_by_repo(marketplace_skills)
return _as_skill_pack_read(pack).model_copy(
update={"skill_count": _pack_skill_count(pack=pack, count_by_repo=count_by_repo)},
)
@router.post("/packs", response_model=SkillPackRead)
async def create_skill_pack(
payload: SkillPackCreate,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> SkillPackRead:
"""Register a new skill pack source URL."""
source_url = str(payload.source_url).strip()
existing = await SkillPack.objects.filter_by(
organization_id=ctx.organization.id,
source_url=source_url,
).first(session)
if existing is not None:
changed = False
if payload.name and existing.name != payload.name:
existing.name = payload.name
changed = True
if payload.description is not None and existing.description != payload.description:
existing.description = payload.description
changed = True
if changed:
existing.updated_at = utcnow()
session.add(existing)
await session.commit()
await session.refresh(existing)
return _as_skill_pack_read(existing)
pack = SkillPack(
organization_id=ctx.organization.id,
source_url=source_url,
name=payload.name or _infer_skill_name(source_url),
description=payload.description,
)
session.add(pack)
await session.commit()
await session.refresh(pack)
marketplace_skills = await MarketplaceSkill.objects.filter_by(
organization_id=ctx.organization.id,
).all(session)
count_by_repo = _build_skill_count_by_repo(marketplace_skills)
return _as_skill_pack_read(pack).model_copy(
update={"skill_count": _pack_skill_count(pack=pack, count_by_repo=count_by_repo)},
)
@router.patch("/packs/{pack_id}", response_model=SkillPackRead)
async def update_skill_pack(
pack_id: UUID,
payload: SkillPackCreate,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> SkillPackRead:
"""Update a skill pack URL and metadata."""
pack = await _require_skill_pack_for_org(pack_id=pack_id, session=session, ctx=ctx)
source_url = str(payload.source_url).strip()
duplicate = await SkillPack.objects.filter_by(
organization_id=ctx.organization.id,
source_url=source_url,
).first(session)
if duplicate is not None and duplicate.id != pack.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A pack with this source URL already exists",
)
pack.source_url = source_url
pack.name = payload.name or _infer_skill_name(source_url)
pack.description = payload.description
pack.updated_at = utcnow()
session.add(pack)
await session.commit()
await session.refresh(pack)
marketplace_skills = await MarketplaceSkill.objects.filter_by(
organization_id=ctx.organization.id,
).all(session)
count_by_repo = _build_skill_count_by_repo(marketplace_skills)
return _as_skill_pack_read(pack).model_copy(
update={"skill_count": _pack_skill_count(pack=pack, count_by_repo=count_by_repo)},
)
@router.delete("/packs/{pack_id}", response_model=OkResponse)
async def delete_skill_pack(
pack_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> OkResponse:
"""Delete one pack source from the organization."""
pack = await _require_skill_pack_for_org(pack_id=pack_id, session=session, ctx=ctx)
await session.delete(pack)
await session.commit()
return OkResponse()
@router.post("/packs/{pack_id}/sync", response_model=SkillPackSyncResponse)
async def sync_skill_pack(
pack_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> SkillPackSyncResponse:
"""Clone a pack repository and upsert discovered skills from `skills/**/SKILL.md`."""
pack = await _require_skill_pack_for_org(pack_id=pack_id, session=session, ctx=ctx)
try:
discovered = _collect_pack_skills(pack.source_url)
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
existing_skills = await MarketplaceSkill.objects.filter_by(
organization_id=ctx.organization.id,
).all(session)
existing_by_source = {skill.source_url: skill for skill in existing_skills}
created = 0
updated = 0
for candidate in discovered:
existing = existing_by_source.get(candidate.source_url)
if existing is None:
session.add(
MarketplaceSkill(
organization_id=ctx.organization.id,
source_url=candidate.source_url,
name=candidate.name,
description=candidate.description,
category=candidate.category,
risk=candidate.risk,
source=candidate.source,
),
)
created += 1
continue
changed = False
if existing.name != candidate.name:
existing.name = candidate.name
changed = True
if existing.description != candidate.description:
existing.description = candidate.description
changed = True
if existing.category != candidate.category:
existing.category = candidate.category
changed = True
if existing.risk != candidate.risk:
existing.risk = candidate.risk
changed = True
if existing.source != candidate.source:
existing.source = candidate.source
changed = True
if changed:
existing.updated_at = utcnow()
session.add(existing)
updated += 1
await session.commit()
return SkillPackSyncResponse(
pack_id=pack.id,
synced=len(discovered),
created=created,
updated=updated,
)

View File

@@ -14,6 +14,7 @@ from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.gateway_installed_skills import GatewayInstalledSkill
from app.models.marketplace_skills import MarketplaceSkill
from app.models.skill_packs import SkillPack
from app.models.organization_board_access import OrganizationBoardAccess
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
from app.models.organization_invites import OrganizationInvite
@@ -46,6 +47,7 @@ __all__ = [
"Gateway",
"GatewayInstalledSkill",
"MarketplaceSkill",
"SkillPack",
"Organization",
"BoardTaskCustomField",
"TaskCustomFieldDefinition",

View File

@@ -30,6 +30,9 @@ class MarketplaceSkill(TenantScoped, table=True):
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
name: str
description: str | None = Field(default=None)
category: str | None = Field(default=None)
risk: str | None = Field(default=None)
source: str | None = Field(default=None)
source_url: str
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -0,0 +1,35 @@
"""Organization-scoped skill pack sources."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint
from sqlmodel import Field
from app.core.time import utcnow
from app.models.tenancy import TenantScoped
RUNTIME_ANNOTATION_TYPES = (datetime,)
class SkillPack(TenantScoped, table=True):
"""A pack repository URL that can be synced into marketplace skills."""
__tablename__ = "skill_packs" # pyright: ignore[reportAssignmentType]
__table_args__ = (
UniqueConstraint(
"organization_id",
"source_url",
name="uq_skill_packs_org_source_url",
),
)
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
name: str
description: str | None = Field(default=None)
source_url: str
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -38,6 +38,9 @@ from app.schemas.skills_marketplace import (
MarketplaceSkillCardRead,
MarketplaceSkillCreate,
MarketplaceSkillRead,
SkillPackCreate,
SkillPackRead,
SkillPackSyncResponse,
)
from app.schemas.souls_directory import (
SoulsDirectoryMarkdownResponse,
@@ -93,6 +96,9 @@ __all__ = [
"MarketplaceSkillCardRead",
"MarketplaceSkillCreate",
"MarketplaceSkillRead",
"SkillPackCreate",
"SkillPackRead",
"SkillPackSyncResponse",
"TagCreate",
"TagRead",
"TagRef",

View File

@@ -21,6 +21,14 @@ class MarketplaceSkillCreate(SQLModel):
description: str | None = None
class SkillPackCreate(SQLModel):
"""Payload used to register a pack URL in the organization."""
source_url: AnyHttpUrl
name: NonEmptyStr | None = None
description: str | None = None
class MarketplaceSkillRead(SQLModel):
"""Serialized marketplace skill catalog record."""
@@ -28,11 +36,27 @@ class MarketplaceSkillRead(SQLModel):
organization_id: UUID
name: str
description: str | None = None
category: str | None = None
risk: str | None = None
source: str | None = None
source_url: str
created_at: datetime
updated_at: datetime
class SkillPackRead(SQLModel):
"""Serialized skill pack record."""
id: UUID
organization_id: UUID
name: str
description: str | None = None
source_url: str
skill_count: int = 0
created_at: datetime
updated_at: datetime
class MarketplaceSkillCardRead(MarketplaceSkillRead):
"""Marketplace card payload with gateway-specific install state."""
@@ -47,3 +71,13 @@ class MarketplaceSkillActionResponse(SQLModel):
skill_id: UUID
gateway_id: UUID
installed: bool
class SkillPackSyncResponse(SQLModel):
"""Pack sync summary payload."""
ok: bool = True
pack_id: UUID
synced: int
created: int
updated: int

View File

@@ -0,0 +1,75 @@
"""add skill packs table
Revision ID: d1b2c3e4f5a6
Revises: c9d7e9b6a4f2
Create Date: 2026-02-14 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "d1b2c3e4f5a6"
down_revision = "c9d7e9b6a4f2"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
indexes = sa.inspect(op.get_bind()).get_indexes(table_name)
return any(index["name"] == index_name for index in indexes)
def upgrade() -> None:
if not _has_table("skill_packs"):
op.create_table(
"skill_packs",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("organization_id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("source_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"organization_id",
"source_url",
name="uq_skill_packs_org_source_url",
),
)
org_idx = op.f("ix_skill_packs_organization_id")
if not _has_index("skill_packs", org_idx):
op.create_index(
org_idx,
"skill_packs",
["organization_id"],
unique=False,
)
def downgrade() -> None:
org_idx = op.f("ix_skill_packs_organization_id")
if _has_index("skill_packs", org_idx):
op.drop_index(
org_idx,
table_name="skill_packs",
)
if _has_table("skill_packs"):
op.drop_table("skill_packs")

View File

@@ -0,0 +1,57 @@
"""add marketplace skill metadata fields
Revision ID: e7a9b1c2d3e4
Revises: d1b2c3e4f5a6
Create Date: 2026-02-14 00:00:01.000000
"""
from __future__ import annotations
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "e7a9b1c2d3e4"
down_revision = "d1b2c3e4f5a6"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
columns = sa.inspect(op.get_bind()).get_columns(table_name)
return any(column["name"] == column_name for column in columns)
def upgrade() -> None:
if not _has_column("marketplace_skills", "category"):
op.add_column(
"marketplace_skills",
sa.Column("category", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
)
if not _has_column("marketplace_skills", "risk"):
op.add_column(
"marketplace_skills",
sa.Column("risk", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
)
if not _has_column("marketplace_skills", "source"):
op.add_column(
"marketplace_skills",
sa.Column("source", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
)
def downgrade() -> None:
if _has_column("marketplace_skills", "source"):
op.drop_column("marketplace_skills", "source")
if _has_column("marketplace_skills", "risk"):
op.drop_column("marketplace_skills", "risk")
if _has_column("marketplace_skills", "category"):
op.drop_column("marketplace_skills", "category")

View File

@@ -3,6 +3,8 @@
from __future__ import annotations
import json
from pathlib import Path
from uuid import uuid4
import pytest
@@ -13,13 +15,18 @@ from sqlmodel import SQLModel, col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import require_org_admin
from app.api.skills_marketplace import router as skills_marketplace_router
from app.api.skills_marketplace import (
PackSkillCandidate,
_collect_pack_skills_from_repo,
router as skills_marketplace_router,
)
from app.db.session import get_session
from app.models.gateway_installed_skills import GatewayInstalledSkill
from app.models.gateways import Gateway
from app.models.marketplace_skills import MarketplaceSkill
from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization
from app.models.skill_packs import SkillPack
from app.services.organizations import OrganizationContext
@@ -219,3 +226,307 @@ async def test_list_marketplace_skills_marks_installed_cards() -> None:
assert cards_by_id[str(second.id)]["installed_at"] is None
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_sync_pack_clones_and_upserts_skills(monkeypatch: pytest.MonkeyPatch) -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
try:
async with session_maker() as session:
organization, _gateway = await _seed_base(session)
pack = SkillPack(
organization_id=organization.id,
name="Antigravity Awesome Skills",
source_url="https://github.com/sickn33/antigravity-awesome-skills",
)
session.add(pack)
await session.commit()
await session.refresh(pack)
app = _build_test_app(session_maker, organization=organization)
collected = [
PackSkillCandidate(
name="Skill Alpha",
description="Alpha description",
source_url="https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha",
category="testing",
risk="low",
source="skills-index",
),
PackSkillCandidate(
name="Skill Beta",
description="Beta description",
source_url="https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beta",
category="automation",
risk="medium",
source="skills-index",
),
]
def _fake_collect_pack_skills(source_url: str) -> list[PackSkillCandidate]:
assert source_url == "https://github.com/sickn33/antigravity-awesome-skills"
return collected
monkeypatch.setattr(
"app.api.skills_marketplace._collect_pack_skills",
_fake_collect_pack_skills,
)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
first_sync = await client.post(f"/api/v1/skills/packs/{pack.id}/sync")
second_sync = await client.post(f"/api/v1/skills/packs/{pack.id}/sync")
assert first_sync.status_code == 200
first_body = first_sync.json()
assert first_body["pack_id"] == str(pack.id)
assert first_body["synced"] == 2
assert first_body["created"] == 2
assert first_body["updated"] == 0
assert second_sync.status_code == 200
second_body = second_sync.json()
assert second_body["pack_id"] == str(pack.id)
assert second_body["synced"] == 2
assert second_body["created"] == 0
assert second_body["updated"] == 0
async with session_maker() as session:
synced_skills = (
await session.exec(
select(MarketplaceSkill).where(
col(MarketplaceSkill.organization_id) == organization.id,
),
)
).all()
assert len(synced_skills) == 2
by_source = {skill.source_url: skill for skill in synced_skills}
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha"
].name
== "Skill Alpha"
)
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha"
].category
== "testing"
)
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha"
].risk
== "low"
)
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beta"
].description
== "Beta description"
)
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beta"
].source
== "skills-index"
)
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_list_skill_packs_includes_skill_count() -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
try:
async with session_maker() as session:
organization, _gateway = await _seed_base(session)
pack = SkillPack(
organization_id=organization.id,
name="Pack One",
source_url="https://github.com/sickn33/antigravity-awesome-skills",
)
session.add(pack)
session.add(
MarketplaceSkill(
organization_id=organization.id,
name="Skill One",
source_url=(
"https://github.com/sickn33/antigravity-awesome-skills"
"/tree/main/skills/alpha"
),
)
)
session.add(
MarketplaceSkill(
organization_id=organization.id,
name="Skill Two",
source_url=(
"https://github.com/sickn33/antigravity-awesome-skills"
"/tree/main/skills/beta"
),
)
)
session.add(
MarketplaceSkill(
organization_id=organization.id,
name="Other Repo Skill",
source_url="https://github.com/other/repo/tree/main/skills/other",
)
)
await session.commit()
app = _build_test_app(session_maker, organization=organization)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.get("/api/v1/skills/packs")
assert response.status_code == 200
items = response.json()
assert len(items) == 1
assert items[0]["name"] == "Pack One"
assert items[0]["skill_count"] == 2
finally:
await engine.dispose()
def test_collect_pack_skills_from_repo_uses_root_index_when_present(tmp_path: Path) -> None:
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "skills").mkdir()
indexed_dir = repo_dir / "skills" / "indexed-fallback"
indexed_dir.mkdir()
(indexed_dir / "SKILL.md").write_text("# Should Not Be Used\n", encoding="utf-8")
(repo_dir / "skills_index.json").write_text(
json.dumps(
[
{
"id": "first",
"name": "Index First",
"description": "From index one",
"path": "skills/index-first",
"category": "uncategorized",
"risk": "unknown",
"source": "index-source",
},
{
"id": "second",
"name": "Index Second",
"description": "From index two",
"path": "skills/index-second/SKILL.md",
"category": "catalog",
"risk": "low",
"source": "index-source",
},
{
"id": "root",
"name": "Root Skill",
"description": "Root from index",
"path": "SKILL.md",
"category": "uncategorized",
"risk": "unknown",
"source": "index-source",
},
]
),
encoding="utf-8",
)
skills = _collect_pack_skills_from_repo(
repo_dir=repo_dir,
source_url="https://github.com/sickn33/antigravity-awesome-skills",
branch="main",
)
assert len(skills) == 3
by_source = {skill.source_url: skill for skill in skills}
assert (
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
in by_source
)
assert (
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-second"
in by_source
)
assert "https://github.com/sickn33/antigravity-awesome-skills/tree/main" in by_source
assert by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
].name == "Index First"
assert by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
].category == "uncategorized"
assert by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
].risk == "unknown"
assert by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
].source == "index-source"
assert by_source["https://github.com/sickn33/antigravity-awesome-skills/tree/main"].name == "Root Skill"
def test_collect_pack_skills_from_repo_supports_root_skill_md(tmp_path: Path) -> None:
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "SKILL.md").write_text(
"---\nname: x-research-skill\ndescription: Root skill package\n---\n",
encoding="utf-8",
)
skills = _collect_pack_skills_from_repo(
repo_dir=repo_dir,
source_url="https://github.com/rohunvora/x-research-skill",
branch="main",
)
assert len(skills) == 1
only_skill = skills[0]
assert only_skill.name == "x-research-skill"
assert only_skill.description == "Root skill package"
assert only_skill.source_url == "https://github.com/rohunvora/x-research-skill/tree/main"
def test_collect_pack_skills_from_repo_supports_top_level_skill_folders(
tmp_path: Path,
) -> None:
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
first = repo_dir / "content-idea-generator"
second = repo_dir / "homepage-audit"
first.mkdir()
second.mkdir()
(first / "SKILL.md").write_text("# Content Idea Generator\n", encoding="utf-8")
(second / "SKILL.md").write_text("# Homepage Audit\n", encoding="utf-8")
skills = _collect_pack_skills_from_repo(
repo_dir=repo_dir,
source_url="https://github.com/BrianRWagner/ai-marketing-skills",
branch="main",
)
assert len(skills) == 2
by_source = {skill.source_url: skill for skill in skills}
assert (
"https://github.com/BrianRWagner/ai-marketing-skills/tree/main/content-idea-generator"
in by_source
)
assert (
"https://github.com/BrianRWagner/ai-marketing-skills/tree/main/homepage-audit"
in by_source
)