feat: add skill pack management features including creation, editing, and syncing
This commit is contained in:
committed by
Abhimanyu Saharan
parent
88565f4d69
commit
a7e1e5cbf4
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
35
backend/app/models/skill_packs.py
Normal file
35
backend/app/models/skill_packs.py
Normal 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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user