diff --git a/backend/app/api/skills_marketplace.py b/backend/app/api/skills_marketplace.py index c6b3506b..41f8e652 100644 --- a/backend/app/api/skills_marketplace.py +++ b/backend/app/api/skills_marketplace.py @@ -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, + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1730bd66..1c28ba4b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/marketplace_skills.py b/backend/app/models/marketplace_skills.py index 9df57ada..432aef9b 100644 --- a/backend/app/models/marketplace_skills.py +++ b/backend/app/models/marketplace_skills.py @@ -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) diff --git a/backend/app/models/skill_packs.py b/backend/app/models/skill_packs.py new file mode 100644 index 00000000..a0bb4f53 --- /dev/null +++ b/backend/app/models/skill_packs.py @@ -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) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index cda5ad54..163e2585 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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", diff --git a/backend/app/schemas/skills_marketplace.py b/backend/app/schemas/skills_marketplace.py index 1a29d6ce..0a608ded 100644 --- a/backend/app/schemas/skills_marketplace.py +++ b/backend/app/schemas/skills_marketplace.py @@ -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 diff --git a/backend/migrations/versions/d1b2c3e4f5a6_add_skill_packs_table.py b/backend/migrations/versions/d1b2c3e4f5a6_add_skill_packs_table.py new file mode 100644 index 00000000..a80c21de --- /dev/null +++ b/backend/migrations/versions/d1b2c3e4f5a6_add_skill_packs_table.py @@ -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") diff --git a/backend/migrations/versions/e7a9b1c2d3e4_add_marketplace_skill_metadata_fields.py b/backend/migrations/versions/e7a9b1c2d3e4_add_marketplace_skill_metadata_fields.py new file mode 100644 index 00000000..9ee9b697 --- /dev/null +++ b/backend/migrations/versions/e7a9b1c2d3e4_add_marketplace_skill_metadata_fields.py @@ -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") diff --git a/backend/tests/test_skills_marketplace_api.py b/backend/tests/test_skills_marketplace_api.py index dadb0156..4ddda932 100644 --- a/backend/tests/test_skills_marketplace_api.py +++ b/backend/tests/test_skills_marketplace_api.py @@ -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 + ) diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index 18bb556d..f4c7f493 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -177,6 +177,9 @@ export * from "./organizationUserRead"; export * from "./readyzReadyzGet200"; export * from "./searchApiV1SoulsDirectorySearchGetParams"; export * from "./sendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams"; +export * from "./skillPackCreate"; +export * from "./skillPackRead"; +export * from "./skillPackSyncResponse"; export * from "./soulsDirectoryMarkdownResponse"; export * from "./soulsDirectorySearchResponse"; export * from "./soulsDirectorySoulRef"; diff --git a/frontend/src/api/generated/model/marketplaceSkillCardRead.ts b/frontend/src/api/generated/model/marketplaceSkillCardRead.ts index 5d0a7c8a..f63b94cd 100644 --- a/frontend/src/api/generated/model/marketplaceSkillCardRead.ts +++ b/frontend/src/api/generated/model/marketplaceSkillCardRead.ts @@ -9,6 +9,7 @@ * Marketplace card payload with gateway-specific install state. */ export interface MarketplaceSkillCardRead { + category?: string | null; created_at: string; description?: string | null; id: string; @@ -16,6 +17,8 @@ export interface MarketplaceSkillCardRead { installed_at?: string | null; name: string; organization_id: string; + risk?: string | null; + source?: string | null; source_url: string; updated_at: string; } diff --git a/frontend/src/api/generated/model/marketplaceSkillRead.ts b/frontend/src/api/generated/model/marketplaceSkillRead.ts index b834ee60..da7673f9 100644 --- a/frontend/src/api/generated/model/marketplaceSkillRead.ts +++ b/frontend/src/api/generated/model/marketplaceSkillRead.ts @@ -9,11 +9,14 @@ * Serialized marketplace skill catalog record. */ export interface MarketplaceSkillRead { + category?: string | null; created_at: string; description?: string | null; id: string; name: string; organization_id: string; + risk?: string | null; + source?: string | null; source_url: string; updated_at: string; } diff --git a/frontend/src/api/generated/model/skillPackCreate.ts b/frontend/src/api/generated/model/skillPackCreate.ts new file mode 100644 index 00000000..40d9ecf4 --- /dev/null +++ b/frontend/src/api/generated/model/skillPackCreate.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +/** + * Payload used to register a pack URL in the organization. + */ +export interface SkillPackCreate { + description?: string | null; + name?: string | null; + /** @minLength 1 */ + source_url: string; +} diff --git a/frontend/src/api/generated/model/skillPackRead.ts b/frontend/src/api/generated/model/skillPackRead.ts new file mode 100644 index 00000000..9214f843 --- /dev/null +++ b/frontend/src/api/generated/model/skillPackRead.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +/** + * Serialized skill pack record. + */ +export interface SkillPackRead { + created_at: string; + description?: string | null; + id: string; + name: string; + organization_id: string; + skill_count?: number; + source_url: string; + updated_at: string; +} diff --git a/frontend/src/api/generated/model/skillPackSyncResponse.ts b/frontend/src/api/generated/model/skillPackSyncResponse.ts new file mode 100644 index 00000000..7b83aa83 --- /dev/null +++ b/frontend/src/api/generated/model/skillPackSyncResponse.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +/** + * Pack sync summary payload. + */ +export interface SkillPackSyncResponse { + created: number; + ok?: boolean; + pack_id: string; + synced: number; + updated: number; +} diff --git a/frontend/src/api/generated/skills/skills.ts b/frontend/src/api/generated/skills/skills.ts new file mode 100644 index 00000000..1e34e5ed --- /dev/null +++ b/frontend/src/api/generated/skills/skills.ts @@ -0,0 +1,1810 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { + HTTPValidationError, + InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams, + ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + MarketplaceSkillActionResponse, + MarketplaceSkillCardRead, + MarketplaceSkillCreate, + MarketplaceSkillRead, + OkResponse, + SkillPackCreate, + SkillPackRead, + SkillPackSyncResponse, + UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams, +} from ".././model"; + +import { customFetch } from "../../mutator"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * List marketplace cards for an org and annotate install state for a gateway. + * @summary List Marketplace Skills + */ +export type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse200 = { + data: MarketplaceSkillCardRead[]; + status: 200; +}; + +export type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponseSuccess = + listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse200 & { + headers: Headers; + }; +export type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponseError = + listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse422 & { + headers: Headers; + }; + +export type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse = + | listMarketplaceSkillsApiV1SkillsMarketplaceGetResponseSuccess + | listMarketplaceSkillsApiV1SkillsMarketplaceGetResponseError; + +export const getListMarketplaceSkillsApiV1SkillsMarketplaceGetUrl = ( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/skills/marketplace?${stringifiedParams}` + : `/api/v1/skills/marketplace`; +}; + +export const listMarketplaceSkillsApiV1SkillsMarketplaceGet = async ( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options?: RequestInit, +): Promise => { + return customFetch( + getListMarketplaceSkillsApiV1SkillsMarketplaceGetUrl(params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryKey = ( + params?: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, +) => { + return [`/api/v1/skills/marketplace`, ...(params ? [params] : [])] as const; +}; + +export const getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryOptions = < + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + listMarketplaceSkillsApiV1SkillsMarketplaceGet(params, { + signal, + ...requestOptions, + }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryResult = + NonNullable< + Awaited> + >; +export type ListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryError = + HTTPValidationError; + +export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List Marketplace Skills + */ + +export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryOptions( + params, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * Register or update a direct marketplace skill URL in the catalog. + * @summary Create Marketplace Skill + */ +export type createMarketplaceSkillApiV1SkillsMarketplacePostResponse200 = { + data: MarketplaceSkillRead; + status: 200; +}; + +export type createMarketplaceSkillApiV1SkillsMarketplacePostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type createMarketplaceSkillApiV1SkillsMarketplacePostResponseSuccess = + createMarketplaceSkillApiV1SkillsMarketplacePostResponse200 & { + headers: Headers; + }; +export type createMarketplaceSkillApiV1SkillsMarketplacePostResponseError = + createMarketplaceSkillApiV1SkillsMarketplacePostResponse422 & { + headers: Headers; + }; + +export type createMarketplaceSkillApiV1SkillsMarketplacePostResponse = + | createMarketplaceSkillApiV1SkillsMarketplacePostResponseSuccess + | createMarketplaceSkillApiV1SkillsMarketplacePostResponseError; + +export const getCreateMarketplaceSkillApiV1SkillsMarketplacePostUrl = () => { + return `/api/v1/skills/marketplace`; +}; + +export const createMarketplaceSkillApiV1SkillsMarketplacePost = async ( + marketplaceSkillCreate: MarketplaceSkillCreate, + options?: RequestInit, +): Promise => { + return customFetch( + getCreateMarketplaceSkillApiV1SkillsMarketplacePostUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(marketplaceSkillCreate), + }, + ); +}; + +export const getCreateMarketplaceSkillApiV1SkillsMarketplacePostMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: MarketplaceSkillCreate }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: MarketplaceSkillCreate }, + TContext + > => { + const mutationKey = ["createMarketplaceSkillApiV1SkillsMarketplacePost"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited< + ReturnType + >, + { data: MarketplaceSkillCreate } + > = (props) => { + const { data } = props ?? {}; + + return createMarketplaceSkillApiV1SkillsMarketplacePost( + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type CreateMarketplaceSkillApiV1SkillsMarketplacePostMutationResult = + NonNullable< + Awaited> + >; +export type CreateMarketplaceSkillApiV1SkillsMarketplacePostMutationBody = + MarketplaceSkillCreate; +export type CreateMarketplaceSkillApiV1SkillsMarketplacePostMutationError = + HTTPValidationError; + +/** + * @summary Create Marketplace Skill + */ +export const useCreateMarketplaceSkillApiV1SkillsMarketplacePost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: MarketplaceSkillCreate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: MarketplaceSkillCreate }, + TContext +> => { + return useMutation( + getCreateMarketplaceSkillApiV1SkillsMarketplacePostMutationOptions(options), + queryClient, + ); +}; +/** + * Delete a marketplace catalog entry and any install records that reference it. + * @summary Delete Marketplace Skill + */ +export type deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponse200 = + { + data: OkResponse; + status: 200; + }; + +export type deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponseSuccess = + deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponse200 & { + headers: Headers; + }; +export type deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponseError = + deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponse422 & { + headers: Headers; + }; + +export type deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponse = + | deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponseSuccess + | deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponseError; + +export const getDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteUrl = ( + skillId: string, +) => { + return `/api/v1/skills/marketplace/${skillId}`; +}; + +export const deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete = async ( + skillId: string, + options?: RequestInit, +): Promise => { + return customFetch( + getDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteUrl(skillId), + { + ...options, + method: "DELETE", + }, + ); +}; + +export const getDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete + > + >, + TError, + { skillId: string }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType< + typeof deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete + > + >, + TError, + { skillId: string }, + TContext + > => { + const mutationKey = [ + "deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete", + ]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited< + ReturnType< + typeof deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete + > + >, + { skillId: string } + > = (props) => { + const { skillId } = props ?? {}; + + return deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete( + skillId, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type DeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteMutationResult = + NonNullable< + Awaited< + ReturnType< + typeof deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete + > + > + >; + +export type DeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteMutationError = + HTTPValidationError; + +/** + * @summary Delete Marketplace Skill + */ +export const useDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete + > + >, + TError, + { skillId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited< + ReturnType + >, + TError, + { skillId: string }, + TContext +> => { + return useMutation( + getDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteMutationOptions( + options, + ), + queryClient, + ); +}; +/** + * Install a marketplace skill by dispatching instructions to the gateway agent. + * @summary Install Marketplace Skill + */ +export type installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponse200 = + { + data: MarketplaceSkillActionResponse; + status: 200; + }; + +export type installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponseSuccess = + installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponse200 & { + headers: Headers; + }; +export type installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponseError = + installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponse422 & { + headers: Headers; + }; + +export type installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponse = + + | installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponseSuccess + | installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponseError; + +export const getInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostUrl = + ( + skillId: string, + params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams, + ) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append( + key, + value === null ? "null" : value.toString(), + ); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/skills/marketplace/${skillId}/install?${stringifiedParams}` + : `/api/v1/skills/marketplace/${skillId}/install`; + }; + +export const installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost = + async ( + skillId: string, + params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams, + options?: RequestInit, + ): Promise => { + return customFetch( + getInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostUrl( + skillId, + params, + ), + { + ...options, + method: "POST", + }, + ); + }; + +export const getInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost + > + >, + TError, + { + skillId: string; + params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams; + }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType< + typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost + > + >, + TError, + { + skillId: string; + params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams; + }, + TContext + > => { + const mutationKey = [ + "installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost", + ]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited< + ReturnType< + typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost + > + >, + { + skillId: string; + params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams; + } + > = (props) => { + const { skillId, params } = props ?? {}; + + return installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost( + skillId, + params, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostMutationResult = + NonNullable< + Awaited< + ReturnType< + typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost + > + > + >; + +export type InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostMutationError = + HTTPValidationError; + +/** + * @summary Install Marketplace Skill + */ +export const useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost = + ( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost + > + >, + TError, + { + skillId: string; + params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams; + }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, + ): UseMutationResult< + Awaited< + ReturnType< + typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost + > + >, + TError, + { + skillId: string; + params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams; + }, + TContext + > => { + return useMutation( + getInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostMutationOptions( + options, + ), + queryClient, + ); + }; +/** + * Uninstall a marketplace skill by dispatching instructions to the gateway agent. + * @summary Uninstall Marketplace Skill + */ +export type uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponse200 = + { + data: MarketplaceSkillActionResponse; + status: 200; + }; + +export type uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponseSuccess = + uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponse200 & { + headers: Headers; + }; +export type uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponseError = + uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponse422 & { + headers: Headers; + }; + +export type uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponse = + + | uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponseSuccess + | uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponseError; + +export const getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostUrl = + ( + skillId: string, + params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams, + ) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append( + key, + value === null ? "null" : value.toString(), + ); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/skills/marketplace/${skillId}/uninstall?${stringifiedParams}` + : `/api/v1/skills/marketplace/${skillId}/uninstall`; + }; + +export const uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost = + async ( + skillId: string, + params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams, + options?: RequestInit, + ): Promise => { + return customFetch( + getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostUrl( + skillId, + params, + ), + { + ...options, + method: "POST", + }, + ); + }; + +export const getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost + > + >, + TError, + { + skillId: string; + params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams; + }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType< + typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost + > + >, + TError, + { + skillId: string; + params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams; + }, + TContext + > => { + const mutationKey = [ + "uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost", + ]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited< + ReturnType< + typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost + > + >, + { + skillId: string; + params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams; + } + > = (props) => { + const { skillId, params } = props ?? {}; + + return uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost( + skillId, + params, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostMutationResult = + NonNullable< + Awaited< + ReturnType< + typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost + > + > + >; + +export type UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostMutationError = + HTTPValidationError; + +/** + * @summary Uninstall Marketplace Skill + */ +export const useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost = + ( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost + > + >, + TError, + { + skillId: string; + params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams; + }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, + ): UseMutationResult< + Awaited< + ReturnType< + typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost + > + >, + TError, + { + skillId: string; + params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams; + }, + TContext + > => { + return useMutation( + getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostMutationOptions( + options, + ), + queryClient, + ); + }; +/** + * List skill packs configured for the organization. + * @summary List Skill Packs + */ +export type listSkillPacksApiV1SkillsPacksGetResponse200 = { + data: SkillPackRead[]; + status: 200; +}; + +export type listSkillPacksApiV1SkillsPacksGetResponseSuccess = + listSkillPacksApiV1SkillsPacksGetResponse200 & { + headers: Headers; + }; +export type listSkillPacksApiV1SkillsPacksGetResponse = + listSkillPacksApiV1SkillsPacksGetResponseSuccess; + +export const getListSkillPacksApiV1SkillsPacksGetUrl = () => { + return `/api/v1/skills/packs`; +}; + +export const listSkillPacksApiV1SkillsPacksGet = async ( + options?: RequestInit, +): Promise => { + return customFetch( + getListSkillPacksApiV1SkillsPacksGetUrl(), + { + ...options, + method: "GET", + }, + ); +}; + +export const getListSkillPacksApiV1SkillsPacksGetQueryKey = () => { + return [`/api/v1/skills/packs`] as const; +}; + +export const getListSkillPacksApiV1SkillsPacksGetQueryOptions = < + TData = Awaited>, + TError = unknown, +>(options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getListSkillPacksApiV1SkillsPacksGetQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + listSkillPacksApiV1SkillsPacksGet({ signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListSkillPacksApiV1SkillsPacksGetQueryResult = NonNullable< + Awaited> +>; +export type ListSkillPacksApiV1SkillsPacksGetQueryError = unknown; + +export function useListSkillPacksApiV1SkillsPacksGet< + TData = Awaited>, + TError = unknown, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListSkillPacksApiV1SkillsPacksGet< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListSkillPacksApiV1SkillsPacksGet< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List Skill Packs + */ + +export function useListSkillPacksApiV1SkillsPacksGet< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getListSkillPacksApiV1SkillsPacksGetQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * Register a new skill pack source URL. + * @summary Create Skill Pack + */ +export type createSkillPackApiV1SkillsPacksPostResponse200 = { + data: SkillPackRead; + status: 200; +}; + +export type createSkillPackApiV1SkillsPacksPostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type createSkillPackApiV1SkillsPacksPostResponseSuccess = + createSkillPackApiV1SkillsPacksPostResponse200 & { + headers: Headers; + }; +export type createSkillPackApiV1SkillsPacksPostResponseError = + createSkillPackApiV1SkillsPacksPostResponse422 & { + headers: Headers; + }; + +export type createSkillPackApiV1SkillsPacksPostResponse = + | createSkillPackApiV1SkillsPacksPostResponseSuccess + | createSkillPackApiV1SkillsPacksPostResponseError; + +export const getCreateSkillPackApiV1SkillsPacksPostUrl = () => { + return `/api/v1/skills/packs`; +}; + +export const createSkillPackApiV1SkillsPacksPost = async ( + skillPackCreate: SkillPackCreate, + options?: RequestInit, +): Promise => { + return customFetch( + getCreateSkillPackApiV1SkillsPacksPostUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(skillPackCreate), + }, + ); +}; + +export const getCreateSkillPackApiV1SkillsPacksPostMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: SkillPackCreate }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: SkillPackCreate }, + TContext +> => { + const mutationKey = ["createSkillPackApiV1SkillsPacksPost"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: SkillPackCreate } + > = (props) => { + const { data } = props ?? {}; + + return createSkillPackApiV1SkillsPacksPost(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CreateSkillPackApiV1SkillsPacksPostMutationResult = NonNullable< + Awaited> +>; +export type CreateSkillPackApiV1SkillsPacksPostMutationBody = SkillPackCreate; +export type CreateSkillPackApiV1SkillsPacksPostMutationError = + HTTPValidationError; + +/** + * @summary Create Skill Pack + */ +export const useCreateSkillPackApiV1SkillsPacksPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: SkillPackCreate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: SkillPackCreate }, + TContext +> => { + return useMutation( + getCreateSkillPackApiV1SkillsPacksPostMutationOptions(options), + queryClient, + ); +}; +/** + * Delete one pack source from the organization. + * @summary Delete Skill Pack + */ +export type deleteSkillPackApiV1SkillsPacksPackIdDeleteResponse200 = { + data: OkResponse; + status: 200; +}; + +export type deleteSkillPackApiV1SkillsPacksPackIdDeleteResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type deleteSkillPackApiV1SkillsPacksPackIdDeleteResponseSuccess = + deleteSkillPackApiV1SkillsPacksPackIdDeleteResponse200 & { + headers: Headers; + }; +export type deleteSkillPackApiV1SkillsPacksPackIdDeleteResponseError = + deleteSkillPackApiV1SkillsPacksPackIdDeleteResponse422 & { + headers: Headers; + }; + +export type deleteSkillPackApiV1SkillsPacksPackIdDeleteResponse = + | deleteSkillPackApiV1SkillsPacksPackIdDeleteResponseSuccess + | deleteSkillPackApiV1SkillsPacksPackIdDeleteResponseError; + +export const getDeleteSkillPackApiV1SkillsPacksPackIdDeleteUrl = ( + packId: string, +) => { + return `/api/v1/skills/packs/${packId}`; +}; + +export const deleteSkillPackApiV1SkillsPacksPackIdDelete = async ( + packId: string, + options?: RequestInit, +): Promise => { + return customFetch( + getDeleteSkillPackApiV1SkillsPacksPackIdDeleteUrl(packId), + { + ...options, + method: "DELETE", + }, + ); +}; + +export const getDeleteSkillPackApiV1SkillsPacksPackIdDeleteMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { packId: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { packId: string }, + TContext +> => { + const mutationKey = ["deleteSkillPackApiV1SkillsPacksPackIdDelete"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { packId: string } + > = (props) => { + const { packId } = props ?? {}; + + return deleteSkillPackApiV1SkillsPacksPackIdDelete(packId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DeleteSkillPackApiV1SkillsPacksPackIdDeleteMutationResult = + NonNullable< + Awaited> + >; + +export type DeleteSkillPackApiV1SkillsPacksPackIdDeleteMutationError = + HTTPValidationError; + +/** + * @summary Delete Skill Pack + */ +export const useDeleteSkillPackApiV1SkillsPacksPackIdDelete = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { packId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { packId: string }, + TContext +> => { + return useMutation( + getDeleteSkillPackApiV1SkillsPacksPackIdDeleteMutationOptions(options), + queryClient, + ); +}; +/** + * Get one skill pack by ID. + * @summary Get Skill Pack + */ +export type getSkillPackApiV1SkillsPacksPackIdGetResponse200 = { + data: SkillPackRead; + status: 200; +}; + +export type getSkillPackApiV1SkillsPacksPackIdGetResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type getSkillPackApiV1SkillsPacksPackIdGetResponseSuccess = + getSkillPackApiV1SkillsPacksPackIdGetResponse200 & { + headers: Headers; + }; +export type getSkillPackApiV1SkillsPacksPackIdGetResponseError = + getSkillPackApiV1SkillsPacksPackIdGetResponse422 & { + headers: Headers; + }; + +export type getSkillPackApiV1SkillsPacksPackIdGetResponse = + | getSkillPackApiV1SkillsPacksPackIdGetResponseSuccess + | getSkillPackApiV1SkillsPacksPackIdGetResponseError; + +export const getGetSkillPackApiV1SkillsPacksPackIdGetUrl = (packId: string) => { + return `/api/v1/skills/packs/${packId}`; +}; + +export const getSkillPackApiV1SkillsPacksPackIdGet = async ( + packId: string, + options?: RequestInit, +): Promise => { + return customFetch( + getGetSkillPackApiV1SkillsPacksPackIdGetUrl(packId), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetSkillPackApiV1SkillsPacksPackIdGetQueryKey = ( + packId: string, +) => { + return [`/api/v1/skills/packs/${packId}`] as const; +}; + +export const getGetSkillPackApiV1SkillsPacksPackIdGetQueryOptions = < + TData = Awaited>, + TError = HTTPValidationError, +>( + packId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetSkillPackApiV1SkillsPacksPackIdGetQueryKey(packId); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getSkillPackApiV1SkillsPacksPackIdGet(packId, { + signal, + ...requestOptions, + }); + + return { + queryKey, + queryFn, + enabled: !!packId, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type GetSkillPackApiV1SkillsPacksPackIdGetQueryResult = NonNullable< + Awaited> +>; +export type GetSkillPackApiV1SkillsPacksPackIdGetQueryError = + HTTPValidationError; + +export function useGetSkillPackApiV1SkillsPacksPackIdGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + packId: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetSkillPackApiV1SkillsPacksPackIdGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + packId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetSkillPackApiV1SkillsPacksPackIdGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + packId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Get Skill Pack + */ + +export function useGetSkillPackApiV1SkillsPacksPackIdGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + packId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getGetSkillPackApiV1SkillsPacksPackIdGetQueryOptions( + packId, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * Update a skill pack URL and metadata. + * @summary Update Skill Pack + */ +export type updateSkillPackApiV1SkillsPacksPackIdPatchResponse200 = { + data: SkillPackRead; + status: 200; +}; + +export type updateSkillPackApiV1SkillsPacksPackIdPatchResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type updateSkillPackApiV1SkillsPacksPackIdPatchResponseSuccess = + updateSkillPackApiV1SkillsPacksPackIdPatchResponse200 & { + headers: Headers; + }; +export type updateSkillPackApiV1SkillsPacksPackIdPatchResponseError = + updateSkillPackApiV1SkillsPacksPackIdPatchResponse422 & { + headers: Headers; + }; + +export type updateSkillPackApiV1SkillsPacksPackIdPatchResponse = + | updateSkillPackApiV1SkillsPacksPackIdPatchResponseSuccess + | updateSkillPackApiV1SkillsPacksPackIdPatchResponseError; + +export const getUpdateSkillPackApiV1SkillsPacksPackIdPatchUrl = ( + packId: string, +) => { + return `/api/v1/skills/packs/${packId}`; +}; + +export const updateSkillPackApiV1SkillsPacksPackIdPatch = async ( + packId: string, + skillPackCreate: SkillPackCreate, + options?: RequestInit, +): Promise => { + return customFetch( + getUpdateSkillPackApiV1SkillsPacksPackIdPatchUrl(packId), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(skillPackCreate), + }, + ); +}; + +export const getUpdateSkillPackApiV1SkillsPacksPackIdPatchMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { packId: string; data: SkillPackCreate }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { packId: string; data: SkillPackCreate }, + TContext +> => { + const mutationKey = ["updateSkillPackApiV1SkillsPacksPackIdPatch"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { packId: string; data: SkillPackCreate } + > = (props) => { + const { packId, data } = props ?? {}; + + return updateSkillPackApiV1SkillsPacksPackIdPatch( + packId, + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateSkillPackApiV1SkillsPacksPackIdPatchMutationResult = + NonNullable< + Awaited> + >; +export type UpdateSkillPackApiV1SkillsPacksPackIdPatchMutationBody = + SkillPackCreate; +export type UpdateSkillPackApiV1SkillsPacksPackIdPatchMutationError = + HTTPValidationError; + +/** + * @summary Update Skill Pack + */ +export const useUpdateSkillPackApiV1SkillsPacksPackIdPatch = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { packId: string; data: SkillPackCreate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { packId: string; data: SkillPackCreate }, + TContext +> => { + return useMutation( + getUpdateSkillPackApiV1SkillsPacksPackIdPatchMutationOptions(options), + queryClient, + ); +}; +/** + * Clone a pack repository and upsert discovered skills from `skills/**\/SKILL.md`. + * @summary Sync Skill Pack + */ +export type syncSkillPackApiV1SkillsPacksPackIdSyncPostResponse200 = { + data: SkillPackSyncResponse; + status: 200; +}; + +export type syncSkillPackApiV1SkillsPacksPackIdSyncPostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type syncSkillPackApiV1SkillsPacksPackIdSyncPostResponseSuccess = + syncSkillPackApiV1SkillsPacksPackIdSyncPostResponse200 & { + headers: Headers; + }; +export type syncSkillPackApiV1SkillsPacksPackIdSyncPostResponseError = + syncSkillPackApiV1SkillsPacksPackIdSyncPostResponse422 & { + headers: Headers; + }; + +export type syncSkillPackApiV1SkillsPacksPackIdSyncPostResponse = + | syncSkillPackApiV1SkillsPacksPackIdSyncPostResponseSuccess + | syncSkillPackApiV1SkillsPacksPackIdSyncPostResponseError; + +export const getSyncSkillPackApiV1SkillsPacksPackIdSyncPostUrl = ( + packId: string, +) => { + return `/api/v1/skills/packs/${packId}/sync`; +}; + +export const syncSkillPackApiV1SkillsPacksPackIdSyncPost = async ( + packId: string, + options?: RequestInit, +): Promise => { + return customFetch( + getSyncSkillPackApiV1SkillsPacksPackIdSyncPostUrl(packId), + { + ...options, + method: "POST", + }, + ); +}; + +export const getSyncSkillPackApiV1SkillsPacksPackIdSyncPostMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { packId: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { packId: string }, + TContext +> => { + const mutationKey = ["syncSkillPackApiV1SkillsPacksPackIdSyncPost"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { packId: string } + > = (props) => { + const { packId } = props ?? {}; + + return syncSkillPackApiV1SkillsPacksPackIdSyncPost(packId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type SyncSkillPackApiV1SkillsPacksPackIdSyncPostMutationResult = + NonNullable< + Awaited> + >; + +export type SyncSkillPackApiV1SkillsPacksPackIdSyncPostMutationError = + HTTPValidationError; + +/** + * @summary Sync Skill Pack + */ +export const useSyncSkillPackApiV1SkillsPacksPackIdSyncPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { packId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { packId: string }, + TContext +> => { + return useMutation( + getSyncSkillPackApiV1SkillsPacksPackIdSyncPostMutationOptions(options), + queryClient, + ); +}; diff --git a/frontend/src/app/skills/marketplace/[skillId]/edit/page.tsx b/frontend/src/app/skills/marketplace/[skillId]/edit/page.tsx new file mode 100644 index 00000000..d5c1720a --- /dev/null +++ b/frontend/src/app/skills/marketplace/[skillId]/edit/page.tsx @@ -0,0 +1,12 @@ +import { redirect } from "next/navigation"; + +type EditMarketplaceSkillPageProps = { + params: Promise<{ skillId: string }>; +}; + +export default async function EditMarketplaceSkillPage({ + params, +}: EditMarketplaceSkillPageProps) { + const { skillId } = await params; + redirect(`/skills/packs/${skillId}/edit`); +} diff --git a/frontend/src/app/skills/marketplace/new/page.tsx b/frontend/src/app/skills/marketplace/new/page.tsx new file mode 100644 index 00000000..5b9349da --- /dev/null +++ b/frontend/src/app/skills/marketplace/new/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function NewMarketplaceSkillPage() { + redirect("/skills/packs/new"); +} diff --git a/frontend/src/app/skills/marketplace/page.tsx b/frontend/src/app/skills/marketplace/page.tsx new file mode 100644 index 00000000..934f078e --- /dev/null +++ b/frontend/src/app/skills/marketplace/page.tsx @@ -0,0 +1,408 @@ +"use client"; + +export const dynamic = "force-dynamic"; + +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; + +import { useAuth } from "@/auth/clerk"; +import { useQueryClient } from "@tanstack/react-query"; + +import { ApiError } from "@/api/mutator"; +import { + type listGatewaysApiV1GatewaysGetResponse, + useListGatewaysApiV1GatewaysGet, +} from "@/api/generated/gateways/gateways"; +import type { MarketplaceSkillCardRead } from "@/api/generated/model"; +import { + listMarketplaceSkillsApiV1SkillsMarketplaceGet, + type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse, + useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost, + useListMarketplaceSkillsApiV1SkillsMarketplaceGet, + useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost, +} from "@/api/generated/skills-marketplace/skills-marketplace"; +import { + type listSkillPacksApiV1SkillsPacksGetResponse, + useListSkillPacksApiV1SkillsPacksGet, +} from "@/api/generated/skills/skills"; +import { MarketplaceSkillsTable } from "@/components/skills/MarketplaceSkillsTable"; +import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useOrganizationMembership } from "@/lib/use-organization-membership"; +import { useUrlSorting } from "@/lib/use-url-sorting"; + +const MARKETPLACE_SKILLS_SORTABLE_COLUMNS = [ + "name", + "category", + "risk", + "source", + "updated_at", +]; + +const normalizeRepoSourceUrl = (sourceUrl: string): string => { + const trimmed = sourceUrl.trim().replace(/\/+$/, ""); + return trimmed.endsWith(".git") ? trimmed.slice(0, -4) : trimmed; +}; + +const repoBaseFromSkillSourceUrl = (skillSourceUrl: string): string | null => { + try { + const parsed = new URL(skillSourceUrl); + const marker = "/tree/"; + const markerIndex = parsed.pathname.indexOf(marker); + if (markerIndex <= 0) return null; + return normalizeRepoSourceUrl( + `${parsed.origin}${parsed.pathname.slice(0, markerIndex)}`, + ); + } catch { + return null; + } +}; + +export default function SkillsMarketplacePage() { + const queryClient = useQueryClient(); + const searchParams = useSearchParams(); + const { isSignedIn } = useAuth(); + const { isAdmin } = useOrganizationMembership(isSignedIn); + const [selectedSkill, setSelectedSkill] = useState(null); + const [gatewayInstalledById, setGatewayInstalledById] = useState< + Record + >({}); + const [isGatewayStatusLoading, setIsGatewayStatusLoading] = useState(false); + const [gatewayStatusError, setGatewayStatusError] = useState(null); + const [installingGatewayId, setInstallingGatewayId] = useState(null); + + const { sorting, onSortingChange } = useUrlSorting({ + allowedColumnIds: MARKETPLACE_SKILLS_SORTABLE_COLUMNS, + defaultSorting: [{ id: "name", desc: false }], + paramPrefix: "skills_marketplace", + }); + + const gatewaysQuery = useListGatewaysApiV1GatewaysGet< + listGatewaysApiV1GatewaysGetResponse, + ApiError + >(undefined, { + query: { + enabled: Boolean(isSignedIn && isAdmin), + refetchOnMount: "always", + refetchInterval: 30_000, + }, + }); + + const gateways = useMemo( + () => + gatewaysQuery.data?.status === 200 + ? (gatewaysQuery.data.data.items ?? []) + : [], + [gatewaysQuery.data], + ); + + const resolvedGatewayId = gateways[0]?.id ?? ""; + + const skillsQuery = useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse, + ApiError + >( + { gateway_id: resolvedGatewayId }, + { + query: { + enabled: Boolean(isSignedIn && isAdmin && resolvedGatewayId), + refetchOnMount: "always", + refetchInterval: 15_000, + }, + }, + ); + + const skills = useMemo( + () => (skillsQuery.data?.status === 200 ? skillsQuery.data.data : []), + [skillsQuery.data], + ); + + const packsQuery = useListSkillPacksApiV1SkillsPacksGet< + listSkillPacksApiV1SkillsPacksGetResponse, + ApiError + >({ + query: { + enabled: Boolean(isSignedIn && isAdmin), + refetchOnMount: "always", + }, + }); + + const packs = useMemo( + () => (packsQuery.data?.status === 200 ? packsQuery.data.data : []), + [packsQuery.data], + ); + + const selectedPackId = searchParams.get("packId"); + const selectedPack = useMemo( + () => packs.find((pack) => pack.id === selectedPackId) ?? null, + [packs, selectedPackId], + ); + + const visibleSkills = useMemo(() => { + if (!selectedPack) return skills; + const selectedRepo = normalizeRepoSourceUrl(selectedPack.source_url); + return skills.filter((skill) => { + const skillRepo = repoBaseFromSkillSourceUrl(skill.source_url); + return skillRepo === selectedRepo; + }); + }, [selectedPack, skills]); + + const installMutation = + useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost( + { + mutation: { + onSuccess: async (_, variables) => { + await queryClient.invalidateQueries({ + queryKey: ["/api/v1/skills/marketplace"], + }); + setGatewayInstalledById((previous) => ({ + ...previous, + [variables.params.gateway_id]: true, + })); + }, + }, + }, + queryClient, + ); + + const uninstallMutation = + useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost( + { + mutation: { + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["/api/v1/skills/marketplace"], + }); + }, + }, + }, + queryClient, + ); + + useEffect(() => { + let cancelled = false; + + const loadGatewayStatus = async () => { + if (!selectedSkill) { + setGatewayInstalledById({}); + setGatewayStatusError(null); + setIsGatewayStatusLoading(false); + return; + } + + if (gateways.length === 0) { + setGatewayInstalledById({}); + setGatewayStatusError(null); + setIsGatewayStatusLoading(false); + return; + } + + setIsGatewayStatusLoading(true); + setGatewayStatusError(null); + try { + const entries = await Promise.all( + gateways.map(async (gateway) => { + const response = await listMarketplaceSkillsApiV1SkillsMarketplaceGet({ + gateway_id: gateway.id, + }); + const row = + response.status === 200 + ? response.data.find((skill) => skill.id === selectedSkill.id) + : null; + return [gateway.id, Boolean(row?.installed)] as const; + }), + ); + if (cancelled) return; + setGatewayInstalledById(Object.fromEntries(entries)); + } catch (error) { + if (cancelled) return; + setGatewayStatusError( + error instanceof Error ? error.message : "Unable to load gateway status.", + ); + } finally { + if (!cancelled) { + setIsGatewayStatusLoading(false); + } + } + }; + + void loadGatewayStatus(); + + return () => { + cancelled = true; + }; + }, [gateways, selectedSkill]); + + const mutationError = + installMutation.error?.message ?? uninstallMutation.error?.message; + + const isMutating = installMutation.isPending || uninstallMutation.isPending; + + const handleInstallToGateway = async (gatewayId: string) => { + if (!selectedSkill) return; + setInstallingGatewayId(gatewayId); + try { + await installMutation.mutateAsync({ + skillId: selectedSkill.id, + params: { gateway_id: gatewayId }, + }); + } finally { + setInstallingGatewayId(null); + } + }; + + return ( + <> + +
+ {gateways.length === 0 ? ( +
+

No gateways available yet.

+

+ Create a gateway first, then return here to manage installs. +

+ + Create gateway + +
+ ) : ( + <> +
+ + uninstallMutation.mutate({ + skillId: skill.id, + params: { gateway_id: resolvedGatewayId }, + }) + } + emptyState={{ + title: "No marketplace skills yet", + description: "Add packs first, then synced skills will appear here.", + actionHref: "/skills/packs/new", + actionLabel: "Add your first pack", + }} + /> +
+ + )} + + {skillsQuery.error ? ( +

{skillsQuery.error.message}

+ ) : null} + {packsQuery.error ? ( +

{packsQuery.error.message}

+ ) : null} + {mutationError ?

{mutationError}

: null} +
+
+ + { + if (!open) { + setSelectedSkill(null); + } + }} + > + + + {selectedSkill ? selectedSkill.name : "Install skill"} + + Choose one or more gateways where this skill should be installed. + + + +
+ {isGatewayStatusLoading ? ( +

Loading gateways...

+ ) : ( + gateways.map((gateway) => { + const isInstalled = gatewayInstalledById[gateway.id] === true; + const isInstalling = + installMutation.isPending && installingGatewayId === gateway.id; + return ( +
+
+

{gateway.name}

+

+ {isInstalled ? "Installed" : "Not installed"} +

+
+ +
+ ); + }) + )} + {gatewayStatusError ? ( +

{gatewayStatusError}

+ ) : null} + {installMutation.error ? ( +

{installMutation.error.message}

+ ) : null} +
+ + + + +
+
+ + ); +} diff --git a/frontend/src/app/skills/packs/[packId]/edit/page.tsx b/frontend/src/app/skills/packs/[packId]/edit/page.tsx new file mode 100644 index 00000000..870f4032 --- /dev/null +++ b/frontend/src/app/skills/packs/[packId]/edit/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +export const dynamic = "force-dynamic"; + +import { useParams, useRouter } from "next/navigation"; + +import { useAuth } from "@/auth/clerk"; + +import { ApiError } from "@/api/mutator"; +import { + type getSkillPackApiV1SkillsPacksPackIdGetResponse, + useGetSkillPackApiV1SkillsPacksPackIdGet, + useUpdateSkillPackApiV1SkillsPacksPackIdPatch, +} from "@/api/generated/skills/skills"; +import { MarketplaceSkillForm } from "@/components/skills/MarketplaceSkillForm"; +import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; +import { useOrganizationMembership } from "@/lib/use-organization-membership"; + +export default function EditSkillPackPage() { + const router = useRouter(); + const params = useParams(); + const { isSignedIn } = useAuth(); + const { isAdmin } = useOrganizationMembership(isSignedIn); + + const packIdParam = params?.packId; + const packId = Array.isArray(packIdParam) ? packIdParam[0] : packIdParam; + + const packQuery = useGetSkillPackApiV1SkillsPacksPackIdGet< + getSkillPackApiV1SkillsPacksPackIdGetResponse, + ApiError + >(packId ?? "", { + query: { + enabled: Boolean(isSignedIn && isAdmin && packId), + refetchOnMount: "always", + retry: false, + }, + }); + + const pack = ( + packQuery.data?.status === 200 ? packQuery.data.data : null + ); + + const saveMutation = useUpdateSkillPackApiV1SkillsPacksPackIdPatch(); + + return ( + + {packQuery.isLoading ? ( +
+ Loading pack... +
+ ) : packQuery.error ? ( +
+ {packQuery.error.message} +
+ ) : !pack ? ( +
+ Pack not found. +
+ ) : ( + router.push("/skills/packs")} + onSubmit={async (values) => { + const result = await saveMutation.mutateAsync({ + packId: pack.id, + data: { + source_url: values.sourceUrl, + name: values.name || undefined, + description: values.description || undefined, + }, + }); + if (result.status !== 200) { + throw new Error("Unable to update pack."); + } + router.push("/skills/packs"); + }} + /> + )} +
+ ); +} diff --git a/frontend/src/app/skills/packs/new/page.tsx b/frontend/src/app/skills/packs/new/page.tsx new file mode 100644 index 00000000..7c2751b0 --- /dev/null +++ b/frontend/src/app/skills/packs/new/page.tsx @@ -0,0 +1,60 @@ +"use client"; + +export const dynamic = "force-dynamic"; + +import { useRouter } from "next/navigation"; + +import { useAuth } from "@/auth/clerk"; + +import { ApiError } from "@/api/mutator"; +import { useCreateSkillPackApiV1SkillsPacksPost } from "@/api/generated/skills/skills"; +import { MarketplaceSkillForm } from "@/components/skills/MarketplaceSkillForm"; +import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; +import { useOrganizationMembership } from "@/lib/use-organization-membership"; + +export default function NewSkillPackPage() { + const router = useRouter(); + const { isSignedIn } = useAuth(); + const { isAdmin } = useOrganizationMembership(isSignedIn); + + const createMutation = useCreateSkillPackApiV1SkillsPacksPost(); + + return ( + + router.push("/skills/packs")} + onSubmit={async (values) => { + const result = await createMutation.mutateAsync({ + data: { + source_url: values.sourceUrl, + name: values.name || undefined, + description: values.description || undefined, + }, + }); + if (result.status !== 200) { + throw new Error("Unable to add pack."); + } + router.push("/skills/packs"); + }} + /> + + ); +} diff --git a/frontend/src/app/skills/packs/page.tsx b/frontend/src/app/skills/packs/page.tsx new file mode 100644 index 00000000..4bafcd07 --- /dev/null +++ b/frontend/src/app/skills/packs/page.tsx @@ -0,0 +1,188 @@ +"use client"; + +export const dynamic = "force-dynamic"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; + +import { useAuth } from "@/auth/clerk"; +import { useQueryClient } from "@tanstack/react-query"; + +import { ApiError } from "@/api/mutator"; +import type { SkillPackRead } from "@/api/generated/model"; +import { + getListSkillPacksApiV1SkillsPacksGetQueryKey, + type listSkillPacksApiV1SkillsPacksGetResponse, + useDeleteSkillPackApiV1SkillsPacksPackIdDelete, + useListSkillPacksApiV1SkillsPacksGet, + useSyncSkillPackApiV1SkillsPacksPackIdSyncPost, +} from "@/api/generated/skills/skills"; +import { SkillPacksTable } from "@/components/skills/SkillPacksTable"; +import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; +import { buttonVariants } from "@/components/ui/button"; +import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; +import { useOrganizationMembership } from "@/lib/use-organization-membership"; +import { useUrlSorting } from "@/lib/use-url-sorting"; + +const PACKS_SORTABLE_COLUMNS = ["name", "source_url", "skill_count", "updated_at"]; + +export default function SkillsPacksPage() { + const queryClient = useQueryClient(); + const { isSignedIn } = useAuth(); + const { isAdmin } = useOrganizationMembership(isSignedIn); + const [deleteTarget, setDeleteTarget] = useState(null); + const [syncingPackIds, setSyncingPackIds] = useState>(new Set()); + + const { sorting, onSortingChange } = useUrlSorting({ + allowedColumnIds: PACKS_SORTABLE_COLUMNS, + defaultSorting: [{ id: "name", desc: false }], + paramPrefix: "skill_packs", + }); + + const packsQuery = useListSkillPacksApiV1SkillsPacksGet< + listSkillPacksApiV1SkillsPacksGetResponse, + ApiError + >({ + query: { + enabled: Boolean(isSignedIn && isAdmin), + refetchOnMount: "always", + refetchInterval: 15_000, + }, + }); + + const packsQueryKey = getListSkillPacksApiV1SkillsPacksGetQueryKey(); + + const packs = useMemo( + () => (packsQuery.data?.status === 200 ? packsQuery.data.data : []), + [packsQuery.data], + ); + + const deleteMutation = + useDeleteSkillPackApiV1SkillsPacksPackIdDelete( + { + mutation: { + onSuccess: async () => { + setDeleteTarget(null); + await queryClient.invalidateQueries({ + queryKey: packsQueryKey, + }); + }, + }, + }, + queryClient, + ); + const syncMutation = + useSyncSkillPackApiV1SkillsPacksPackIdSyncPost( + { + mutation: { + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: packsQueryKey, + }); + }, + }, + }, + queryClient, + ); + + const handleDelete = () => { + if (!deleteTarget) return; + deleteMutation.mutate({ packId: deleteTarget.id }); + }; + + return ( + <> + + Add pack + + ) : null + } + isAdmin={isAdmin} + adminOnlyMessage="Only organization owners and admins can manage skill packs." + stickyHeader + > +
+
+ `/skills/packs/${pack.id}/edit`} + canSync + syncingPackIds={syncingPackIds} + onSync={(pack) => { + void (async () => { + setSyncingPackIds((previous) => { + const next = new Set(previous); + next.add(pack.id); + return next; + }); + try { + await syncMutation.mutateAsync({ + packId: pack.id, + }); + } finally { + setSyncingPackIds((previous) => { + const next = new Set(previous); + next.delete(pack.id); + return next; + }); + } + })(); + }} + onDelete={setDeleteTarget} + emptyState={{ + title: "No packs yet", + description: "Add your first skill URL pack to get started.", + actionHref: "/skills/packs/new", + actionLabel: "Add your first pack", + }} + /> +
+ + {packsQuery.error ? ( +

{packsQuery.error.message}

+ ) : null} + {deleteMutation.error ? ( +

{deleteMutation.error.message}

+ ) : null} + {syncMutation.error ? ( +

{syncMutation.error.message}

+ ) : null} +
+
+ + { + if (!open) setDeleteTarget(null); + }} + ariaLabel="Delete skill pack" + title="Delete skill pack" + description={ + <> + This will remove {deleteTarget?.name} from your + pack list. This action cannot be undone. + + } + errorMessage={deleteMutation.error?.message} + onConfirm={handleDelete} + isConfirming={deleteMutation.isPending} + /> + + ); +} diff --git a/frontend/src/app/skills/page.tsx b/frontend/src/app/skills/page.tsx index 1c7cc24f..d8b663f9 100644 --- a/frontend/src/app/skills/page.tsx +++ b/frontend/src/app/skills/page.tsx @@ -1,412 +1,5 @@ -"use client"; +import { redirect } from "next/navigation"; -export const dynamic = "force-dynamic"; - -import Link from "next/link"; -import { FormEvent, useMemo, useState } from "react"; - -import { useAuth } from "@/auth/clerk"; -import { useQueryClient } from "@tanstack/react-query"; -import { ExternalLink, Package, PlusCircle, Trash2 } from "lucide-react"; - -import { ApiError } from "@/api/mutator"; -import { - type listGatewaysApiV1GatewaysGetResponse, - useListGatewaysApiV1GatewaysGet, -} from "@/api/generated/gateways/gateways"; -import type { MarketplaceSkillCardRead } from "@/api/generated/model"; -import { - getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryKey, - type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse, - useCreateMarketplaceSkillApiV1SkillsMarketplacePost, - useDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete, - useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost, - useListMarketplaceSkillsApiV1SkillsMarketplaceGet, - useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost, -} from "@/api/generated/skills-marketplace/skills-marketplace"; -import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; -import { Badge } from "@/components/ui/badge"; -import { Button, buttonVariants } from "@/components/ui/button"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Textarea } from "@/components/ui/textarea"; -import { formatRelativeTimestamp } from "@/lib/formatters"; -import { useOrganizationMembership } from "@/lib/use-organization-membership"; - -export default function SkillsMarketplacePage() { - const queryClient = useQueryClient(); - const { isSignedIn } = useAuth(); - const { isAdmin } = useOrganizationMembership(isSignedIn); - const [selectedGatewayId, setSelectedGatewayId] = useState(""); - const [sourceUrl, setSourceUrl] = useState(""); - const [skillName, setSkillName] = useState(""); - const [description, setDescription] = useState(""); - - const gatewaysQuery = useListGatewaysApiV1GatewaysGet< - listGatewaysApiV1GatewaysGetResponse, - ApiError - >(undefined, { - query: { - enabled: Boolean(isSignedIn && isAdmin), - refetchOnMount: "always", - refetchInterval: 30_000, - }, - }); - - const gateways = useMemo( - () => - gatewaysQuery.data?.status === 200 - ? (gatewaysQuery.data.data.items ?? []) - : [], - [gatewaysQuery.data], - ); - - const resolvedGatewayId = useMemo(() => { - if (selectedGatewayId && gateways.some((gateway) => gateway.id === selectedGatewayId)) { - return selectedGatewayId; - } - return gateways[0]?.id ?? ""; - }, [gateways, selectedGatewayId]); - - const skillsQueryKey = getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryKey( - resolvedGatewayId ? { gateway_id: resolvedGatewayId } : undefined, - ); - - const skillsQuery = useListMarketplaceSkillsApiV1SkillsMarketplaceGet< - listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse, - ApiError - >( - { gateway_id: resolvedGatewayId }, - { - query: { - enabled: Boolean(isSignedIn && isAdmin && resolvedGatewayId), - refetchOnMount: "always", - refetchInterval: 15_000, - }, - }, - ); - - const skills = useMemo( - () => (skillsQuery.data?.status === 200 ? skillsQuery.data.data : []), - [skillsQuery.data], - ); - - const createMutation = - useCreateMarketplaceSkillApiV1SkillsMarketplacePost( - { - mutation: { - onSuccess: async () => { - setSourceUrl(""); - setSkillName(""); - setDescription(""); - await queryClient.invalidateQueries({ - queryKey: skillsQueryKey, - }); - }, - }, - }, - queryClient, - ); - - const installMutation = - useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost( - { - mutation: { - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: skillsQueryKey, - }); - }, - }, - }, - queryClient, - ); - - const uninstallMutation = - useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost( - { - mutation: { - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: skillsQueryKey, - }); - }, - }, - }, - queryClient, - ); - - const deleteMutation = - useDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete( - { - mutation: { - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: skillsQueryKey, - }); - }, - }, - }, - queryClient, - ); - - const mutationError = - createMutation.error?.message ?? - installMutation.error?.message ?? - uninstallMutation.error?.message ?? - deleteMutation.error?.message; - - const handleAddSkill = (event: FormEvent) => { - event.preventDefault(); - const normalizedUrl = sourceUrl.trim(); - if (!normalizedUrl) return; - createMutation.mutate({ - data: { - source_url: normalizedUrl, - name: skillName.trim() || undefined, - description: description.trim() || undefined, - }, - }); - }; - - const isMutating = - createMutation.isPending || - installMutation.isPending || - uninstallMutation.isPending || - deleteMutation.isPending; - - return ( - -
- {gateways.length === 0 ? ( -
-

No gateways available yet.

-

- Create a gateway first, then return here to install skills. -

- - Create gateway - -
- ) : ( - - -

- Add skill source -

-

- Add a URL once, then install or uninstall the skill for the selected gateway. -

-
- -
-
-
- - -
-
- - setSourceUrl(event.target.value)} - placeholder="https://github.com/org/skill-repo" - required - /> -
-
- -
-
- - setSkillName(event.target.value)} - placeholder="Deploy Helper" - /> -
-
- -