diff --git a/backend/app/api/skills_marketplace.py b/backend/app/api/skills_marketplace.py new file mode 100644 index 00000000..c6b3506b --- /dev/null +++ b/backend/app/api/skills_marketplace.py @@ -0,0 +1,313 @@ +"""Skills marketplace API for catalog management and gateway install actions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from urllib.parse import unquote, urlparse +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlmodel import col + +from app.api.deps import require_org_admin +from app.core.time import utcnow +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.schemas.common import OkResponse +from app.schemas.skills_marketplace import ( + MarketplaceSkillActionResponse, + MarketplaceSkillCardRead, + MarketplaceSkillCreate, + MarketplaceSkillRead, +) +from app.services.openclaw.gateway_dispatch import GatewayDispatchService +from app.services.openclaw.gateway_resolver import gateway_client_config, require_gateway_workspace_root +from app.services.openclaw.gateway_rpc import OpenClawGatewayError +from app.services.openclaw.shared import GatewayAgentIdentity +from app.services.organizations import OrganizationContext + +if TYPE_CHECKING: + from sqlmodel.ext.asyncio.session import AsyncSession + +router = APIRouter(prefix="/skills", tags=["skills"]) +SESSION_DEP = Depends(get_session) +ORG_ADMIN_DEP = Depends(require_org_admin) +GATEWAY_ID_QUERY = Query(...) + + +def _skills_install_dir(workspace_root: str) -> str: + normalized = workspace_root.rstrip("/\\") + if not normalized: + return "skills" + return f"{normalized}/skills" + + +def _infer_skill_name(source_url: str) -> str: + parsed = urlparse(source_url) + path = parsed.path.rstrip("/") + candidate = path.rsplit("/", maxsplit=1)[-1] if path else parsed.netloc + candidate = unquote(candidate).removesuffix(".git").replace("-", " ").replace("_", " ") + if candidate.strip(): + return candidate.strip() + return "Skill" + + +def _install_instruction(*, skill: MarketplaceSkill, gateway: Gateway) -> str: + install_dir = _skills_install_dir(gateway.workspace_root) + return ( + "MISSION CONTROL SKILL INSTALL REQUEST\n" + f"Skill name: {skill.name}\n" + f"Skill source URL: {skill.source_url}\n" + f"Install destination: {install_dir}\n\n" + "Actions:\n" + "1. Ensure the install destination exists.\n" + "2. Install or update the skill from the source URL into the destination.\n" + "3. Verify the skill is discoverable by the runtime.\n" + "4. Reply with success or failure details." + ) + + +def _uninstall_instruction(*, skill: MarketplaceSkill, gateway: Gateway) -> str: + install_dir = _skills_install_dir(gateway.workspace_root) + return ( + "MISSION CONTROL SKILL UNINSTALL REQUEST\n" + f"Skill name: {skill.name}\n" + f"Skill source URL: {skill.source_url}\n" + f"Install destination: {install_dir}\n\n" + "Actions:\n" + "1. Remove the skill assets previously installed from this source URL.\n" + "2. Ensure the skill is no longer discoverable by the runtime.\n" + "3. Reply with success or failure details." + ) + + +def _as_card( + *, + skill: MarketplaceSkill, + installation: GatewayInstalledSkill | None, +) -> MarketplaceSkillCardRead: + return MarketplaceSkillCardRead( + id=skill.id, + organization_id=skill.organization_id, + name=skill.name, + description=skill.description, + source_url=skill.source_url, + created_at=skill.created_at, + updated_at=skill.updated_at, + installed=installation is not None, + installed_at=installation.created_at if installation is not None else None, + ) + + +async def _require_gateway_for_org( + *, + gateway_id: UUID, + session: AsyncSession, + ctx: OrganizationContext, +) -> Gateway: + gateway = await Gateway.objects.by_id(gateway_id).first(session) + if gateway is None or gateway.organization_id != ctx.organization.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Gateway not found", + ) + return gateway + + +async def _require_marketplace_skill_for_org( + *, + skill_id: UUID, + session: AsyncSession, + ctx: OrganizationContext, +) -> MarketplaceSkill: + skill = await MarketplaceSkill.objects.by_id(skill_id).first(session) + if skill is None or skill.organization_id != ctx.organization.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Marketplace skill not found", + ) + return skill + + +async def _dispatch_gateway_instruction( + *, + session: AsyncSession, + gateway: Gateway, + message: str, +) -> None: + dispatch = GatewayDispatchService(session) + config = gateway_client_config(gateway) + session_key = GatewayAgentIdentity.session_key(gateway) + await dispatch.send_agent_message( + session_key=session_key, + config=config, + agent_name="Gateway Agent", + message=message, + deliver=True, + ) + + +@router.get("/marketplace", response_model=list[MarketplaceSkillCardRead]) +async def list_marketplace_skills( + gateway_id: UUID = GATEWAY_ID_QUERY, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> list[MarketplaceSkillCardRead]: + """List marketplace cards for an org and annotate install state for a gateway.""" + gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx) + skills = ( + await MarketplaceSkill.objects.filter_by(organization_id=ctx.organization.id) + .order_by(col(MarketplaceSkill.created_at).desc()) + .all(session) + ) + installations = await GatewayInstalledSkill.objects.filter_by(gateway_id=gateway.id).all(session) + installed_by_skill_id = {record.skill_id: record for record in installations} + return [ + _as_card(skill=skill, installation=installed_by_skill_id.get(skill.id)) + for skill in skills + ] + + +@router.post("/marketplace", response_model=MarketplaceSkillRead) +async def create_marketplace_skill( + payload: MarketplaceSkillCreate, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> MarketplaceSkill: + """Register a skill source URL in the organization's marketplace catalog.""" + source_url = str(payload.source_url).strip() + existing = await MarketplaceSkill.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 existing + + skill = MarketplaceSkill( + organization_id=ctx.organization.id, + source_url=source_url, + name=payload.name or _infer_skill_name(source_url), + description=payload.description, + ) + session.add(skill) + await session.commit() + await session.refresh(skill) + return skill + + +@router.delete("/marketplace/{skill_id}", response_model=OkResponse) +async def delete_marketplace_skill( + skill_id: UUID, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> OkResponse: + """Delete a marketplace catalog entry and any install records that reference it.""" + skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx) + installations = await GatewayInstalledSkill.objects.filter_by(skill_id=skill.id).all(session) + for installation in installations: + await session.delete(installation) + await session.delete(skill) + await session.commit() + return OkResponse() + + +@router.post( + "/marketplace/{skill_id}/install", + response_model=MarketplaceSkillActionResponse, +) +async def install_marketplace_skill( + skill_id: UUID, + gateway_id: UUID = GATEWAY_ID_QUERY, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> MarketplaceSkillActionResponse: + """Install a marketplace skill by dispatching instructions to the gateway agent.""" + gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx) + require_gateway_workspace_root(gateway) + skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx) + try: + await _dispatch_gateway_instruction( + session=session, + gateway=gateway, + message=_install_instruction(skill=skill, gateway=gateway), + ) + except OpenClawGatewayError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + + installation = await GatewayInstalledSkill.objects.filter_by( + gateway_id=gateway.id, + skill_id=skill.id, + ).first(session) + if installation is None: + session.add( + GatewayInstalledSkill( + gateway_id=gateway.id, + skill_id=skill.id, + ), + ) + else: + installation.updated_at = utcnow() + session.add(installation) + await session.commit() + return MarketplaceSkillActionResponse( + skill_id=skill.id, + gateway_id=gateway.id, + installed=True, + ) + + +@router.post( + "/marketplace/{skill_id}/uninstall", + response_model=MarketplaceSkillActionResponse, +) +async def uninstall_marketplace_skill( + skill_id: UUID, + gateway_id: UUID = GATEWAY_ID_QUERY, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> MarketplaceSkillActionResponse: + """Uninstall a marketplace skill by dispatching instructions to the gateway agent.""" + gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx) + require_gateway_workspace_root(gateway) + skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx) + try: + await _dispatch_gateway_instruction( + session=session, + gateway=gateway, + message=_uninstall_instruction(skill=skill, gateway=gateway), + ) + except OpenClawGatewayError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + + installation = await GatewayInstalledSkill.objects.filter_by( + gateway_id=gateway.id, + skill_id=skill.id, + ).first(session) + if installation is not None: + await session.delete(installation) + await session.commit() + return MarketplaceSkillActionResponse( + skill_id=skill.id, + gateway_id=gateway.id, + installed=False, + ) diff --git a/backend/app/main.py b/backend/app/main.py index c5fb3461..b37e94bf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -25,6 +25,7 @@ from app.api.gateways import router as gateways_router from app.api.metrics import router as metrics_router from app.api.organizations import router as organizations_router from app.api.souls_directory import router as souls_directory_router +from app.api.skills_marketplace import router as skills_marketplace_router from app.api.tags import router as tags_router from app.api.task_custom_fields import router as task_custom_fields_router from app.api.tasks import router as tasks_router @@ -138,6 +139,7 @@ api_v1.include_router(gateways_router) api_v1.include_router(metrics_router) api_v1.include_router(organizations_router) api_v1.include_router(souls_directory_router) +api_v1.include_router(skills_marketplace_router) api_v1.include_router(board_groups_router) api_v1.include_router(board_group_memory_router) api_v1.include_router(boards_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1f5c29c0..1730bd66 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -12,6 +12,8 @@ from app.models.board_webhook_payloads import BoardWebhookPayload from app.models.board_webhooks import BoardWebhook 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.organization_board_access import OrganizationBoardAccess from app.models.organization_invite_board_access import OrganizationInviteBoardAccess from app.models.organization_invites import OrganizationInvite @@ -42,6 +44,8 @@ __all__ = [ "BoardGroup", "Board", "Gateway", + "GatewayInstalledSkill", + "MarketplaceSkill", "Organization", "BoardTaskCustomField", "TaskCustomFieldDefinition", diff --git a/backend/app/models/gateway_installed_skills.py b/backend/app/models/gateway_installed_skills.py new file mode 100644 index 00000000..2222395f --- /dev/null +++ b/backend/app/models/gateway_installed_skills.py @@ -0,0 +1,33 @@ +"""Gateway-to-skill installation state records.""" + +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.base import QueryModel + +RUNTIME_ANNOTATION_TYPES = (datetime,) + + +class GatewayInstalledSkill(QueryModel, table=True): + """Marks that a marketplace skill is installed for a specific gateway.""" + + __tablename__ = "gateway_installed_skills" # pyright: ignore[reportAssignmentType] + __table_args__ = ( + UniqueConstraint( + "gateway_id", + "skill_id", + name="uq_gateway_installed_skills_gateway_id_skill_id", + ), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + gateway_id: UUID = Field(foreign_key="gateways.id", index=True) + skill_id: UUID = Field(foreign_key="marketplace_skills.id", index=True) + created_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/models/marketplace_skills.py b/backend/app/models/marketplace_skills.py new file mode 100644 index 00000000..9df57ada --- /dev/null +++ b/backend/app/models/marketplace_skills.py @@ -0,0 +1,35 @@ +"""Organization-scoped skill catalog entries for the skills marketplace.""" + +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 MarketplaceSkill(TenantScoped, table=True): + """A marketplace skill entry that can be installed onto one or more gateways.""" + + __tablename__ = "marketplace_skills" # pyright: ignore[reportAssignmentType] + __table_args__ = ( + UniqueConstraint( + "organization_id", + "source_url", + name="uq_marketplace_skills_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 0843a445..cda5ad54 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -33,6 +33,12 @@ from app.schemas.organizations import ( OrganizationMemberUpdate, OrganizationRead, ) +from app.schemas.skills_marketplace import ( + MarketplaceSkillActionResponse, + MarketplaceSkillCardRead, + MarketplaceSkillCreate, + MarketplaceSkillRead, +) from app.schemas.souls_directory import ( SoulsDirectoryMarkdownResponse, SoulsDirectorySearchResponse, @@ -83,6 +89,10 @@ __all__ = [ "SoulsDirectoryMarkdownResponse", "SoulsDirectorySearchResponse", "SoulsDirectorySoulRef", + "MarketplaceSkillActionResponse", + "MarketplaceSkillCardRead", + "MarketplaceSkillCreate", + "MarketplaceSkillRead", "TagCreate", "TagRead", "TagRef", diff --git a/backend/app/schemas/skills_marketplace.py b/backend/app/schemas/skills_marketplace.py new file mode 100644 index 00000000..1a29d6ce --- /dev/null +++ b/backend/app/schemas/skills_marketplace.py @@ -0,0 +1,49 @@ +"""Schemas for skills marketplace listing and install/uninstall actions.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import AnyHttpUrl +from sqlmodel import SQLModel + +from app.schemas.common import NonEmptyStr + +RUNTIME_ANNOTATION_TYPES = (datetime, UUID, NonEmptyStr) + + +class MarketplaceSkillCreate(SQLModel): + """Payload used to register a skill URL in the organization marketplace.""" + + source_url: AnyHttpUrl + name: NonEmptyStr | None = None + description: str | None = None + + +class MarketplaceSkillRead(SQLModel): + """Serialized marketplace skill catalog record.""" + + id: UUID + organization_id: UUID + name: str + description: str | None = None + source_url: str + created_at: datetime + updated_at: datetime + + +class MarketplaceSkillCardRead(MarketplaceSkillRead): + """Marketplace card payload with gateway-specific install state.""" + + installed: bool + installed_at: datetime | None = None + + +class MarketplaceSkillActionResponse(SQLModel): + """Install/uninstall action response payload.""" + + ok: bool = True + skill_id: UUID + gateway_id: UUID + installed: bool diff --git a/backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py b/backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py new file mode 100644 index 00000000..f1f278e1 --- /dev/null +++ b/backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py @@ -0,0 +1,99 @@ +"""add skills marketplace tables + +Revision ID: c9d7e9b6a4f2 +Revises: b6f4c7d9e1a2 +Create Date: 2026-02-13 00:00:00.000000 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c9d7e9b6a4f2" +down_revision = "b6f4c7d9e1a2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "marketplace_skills", + 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_marketplace_skills_org_source_url", + ), + ) + op.create_index( + op.f("ix_marketplace_skills_organization_id"), + "marketplace_skills", + ["organization_id"], + unique=False, + ) + op.create_table( + "gateway_installed_skills", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("gateway_id", sa.Uuid(), nullable=False), + sa.Column("skill_id", sa.Uuid(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["gateway_id"], + ["gateways.id"], + ), + sa.ForeignKeyConstraint( + ["skill_id"], + ["marketplace_skills.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "gateway_id", + "skill_id", + name="uq_gateway_installed_skills_gateway_id_skill_id", + ), + ) + op.create_index( + op.f("ix_gateway_installed_skills_gateway_id"), + "gateway_installed_skills", + ["gateway_id"], + unique=False, + ) + op.create_index( + op.f("ix_gateway_installed_skills_skill_id"), + "gateway_installed_skills", + ["skill_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_gateway_installed_skills_skill_id"), + table_name="gateway_installed_skills", + ) + op.drop_index( + op.f("ix_gateway_installed_skills_gateway_id"), + table_name="gateway_installed_skills", + ) + op.drop_table("gateway_installed_skills") + op.drop_index( + op.f("ix_marketplace_skills_organization_id"), + table_name="marketplace_skills", + ) + op.drop_table("marketplace_skills") diff --git a/backend/tests/test_skills_marketplace_api.py b/backend/tests/test_skills_marketplace_api.py new file mode 100644 index 00000000..dadb0156 --- /dev/null +++ b/backend/tests/test_skills_marketplace_api.py @@ -0,0 +1,221 @@ +# ruff: noqa: INP001 +"""Integration tests for skills marketplace APIs.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from fastapi import APIRouter, FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine +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.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.services.organizations import OrganizationContext + + +async def _make_engine() -> AsyncEngine: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.connect() as conn, conn.begin(): + await conn.run_sync(SQLModel.metadata.create_all) + return engine + + +def _build_test_app( + session_maker: async_sessionmaker[AsyncSession], + *, + organization: Organization, +) -> FastAPI: + app = FastAPI() + api_v1 = APIRouter(prefix="/api/v1") + api_v1.include_router(skills_marketplace_router) + app.include_router(api_v1) + + async def _override_get_session() -> AsyncSession: + async with session_maker() as session: + yield session + + async def _override_require_org_admin() -> OrganizationContext: + return OrganizationContext( + organization=organization, + member=OrganizationMember( + organization_id=organization.id, + user_id=uuid4(), + role="owner", + all_boards_read=True, + all_boards_write=True, + ), + ) + + app.dependency_overrides[get_session] = _override_get_session + app.dependency_overrides[require_org_admin] = _override_require_org_admin + return app + + +async def _seed_base( + session: AsyncSession, +) -> tuple[Organization, Gateway]: + organization = Organization(id=uuid4(), name="Org One") + gateway = Gateway( + id=uuid4(), + organization_id=organization.id, + name="Gateway One", + url="https://gateway.example.local", + workspace_root="/workspace/openclaw", + ) + session.add(organization) + session.add(gateway) + await session.commit() + return organization, gateway + + +@pytest.mark.asyncio +async def test_install_skill_dispatches_instruction_and_persists_installation( + 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) + skill = MarketplaceSkill( + organization_id=organization.id, + name="Deploy Helper", + source_url="https://example.com/skills/deploy-helper.git", + description="Handles deploy workflow checks.", + ) + session.add(skill) + await session.commit() + await session.refresh(skill) + + app = _build_test_app(session_maker, organization=organization) + sent_messages: list[dict[str, str | bool]] = [] + + async def _fake_send_agent_message( + _self: object, + *, + session_key: str, + config: object, + agent_name: str, + message: str, + deliver: bool = False, + ) -> None: + del config + sent_messages.append( + { + "session_key": session_key, + "agent_name": agent_name, + "message": message, + "deliver": deliver, + }, + ) + + monkeypatch.setattr( + "app.api.skills_marketplace.GatewayDispatchService.send_agent_message", + _fake_send_agent_message, + ) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.post( + f"/api/v1/skills/marketplace/{skill.id}/install", + params={"gateway_id": str(gateway.id)}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["installed"] is True + assert body["gateway_id"] == str(gateway.id) + assert len(sent_messages) == 1 + assert sent_messages[0]["agent_name"] == "Gateway Agent" + assert sent_messages[0]["deliver"] is True + assert sent_messages[0]["session_key"] == f"agent:mc-gateway-{gateway.id}:main" + message = str(sent_messages[0]["message"]) + assert "SKILL INSTALL REQUEST" in message + assert str(skill.source_url) in message + assert "/workspace/openclaw/skills" in message + + async with session_maker() as session: + installed_rows = ( + await session.exec( + select(GatewayInstalledSkill).where( + col(GatewayInstalledSkill.gateway_id) == gateway.id, + col(GatewayInstalledSkill.skill_id) == skill.id, + ), + ) + ).all() + assert len(installed_rows) == 1 + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_list_marketplace_skills_marks_installed_cards() -> 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) + first = MarketplaceSkill( + organization_id=organization.id, + name="First Skill", + source_url="https://example.com/skills/first", + ) + second = MarketplaceSkill( + organization_id=organization.id, + name="Second Skill", + source_url="https://example.com/skills/second", + ) + session.add(first) + session.add(second) + await session.commit() + await session.refresh(first) + await session.refresh(second) + + session.add( + GatewayInstalledSkill( + gateway_id=gateway.id, + skill_id=first.id, + ), + ) + 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/marketplace", + params={"gateway_id": str(gateway.id)}, + ) + + assert response.status_code == 200 + cards = response.json() + assert len(cards) == 2 + cards_by_id = {item["id"]: item for item in cards} + assert cards_by_id[str(first.id)]["installed"] is True + assert cards_by_id[str(first.id)]["installed_at"] is not None + assert cards_by_id[str(second.id)]["installed"] is False + assert cards_by_id[str(second.id)]["installed_at"] is None + finally: + await engine.dispose() diff --git a/frontend/src/api/generated/model/approvalCreate.ts b/frontend/src/api/generated/model/approvalCreate.ts index 0f2a963c..320c7a0a 100644 --- a/frontend/src/api/generated/model/approvalCreate.ts +++ b/frontend/src/api/generated/model/approvalCreate.ts @@ -19,6 +19,7 @@ export interface ApprovalCreate { * @maximum 100 */ confidence: number; + lead_reasoning?: string | null; payload?: ApprovalCreatePayload; rubric_scores?: ApprovalCreateRubricScores; status?: ApprovalCreateStatus; diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index da94c881..18bb556d 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -114,6 +114,7 @@ export * from "./getSessionHistoryApiV1GatewaysSessionsSessionIdHistoryGetParams export * from "./healthHealthGet200"; export * from "./healthzHealthzGet200"; export * from "./hTTPValidationError"; +export * from "./installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams"; export * from "./limitOffsetPageTypeVarCustomizedActivityEventRead"; export * from "./limitOffsetPageTypeVarCustomizedActivityTaskCommentFeedItemRead"; export * from "./limitOffsetPageTypeVarCustomizedAgentRead"; @@ -146,6 +147,7 @@ export * from "./listBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayl export * from "./listBoardWebhooksApiV1BoardsBoardIdWebhooksGetParams"; export * from "./listGatewaysApiV1GatewaysGetParams"; export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams"; +export * from "./listMarketplaceSkillsApiV1SkillsMarketplaceGetParams"; export * from "./listOrgInvitesApiV1OrganizationsMeInvitesGetParams"; export * from "./listOrgMembersApiV1OrganizationsMeMembersGetParams"; export * from "./listTagsApiV1TagsGetParams"; @@ -154,6 +156,10 @@ export * from "./listTaskCommentsApiV1AgentBoardsBoardIdTasksTaskIdCommentsGetPa export * from "./listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGetParams"; export * from "./listTasksApiV1AgentBoardsBoardIdTasksGetParams"; export * from "./listTasksApiV1BoardsBoardIdTasksGetParams"; +export * from "./marketplaceSkillActionResponse"; +export * from "./marketplaceSkillCardRead"; +export * from "./marketplaceSkillCreate"; +export * from "./marketplaceSkillRead"; export * from "./okResponse"; export * from "./organizationActiveUpdate"; export * from "./organizationBoardAccessRead"; @@ -207,6 +213,7 @@ export * from "./taskReadCustomFieldValues"; export * from "./taskReadStatus"; export * from "./taskUpdate"; export * from "./taskUpdateCustomFieldValues"; +export * from "./uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams"; export * from "./updateAgentApiV1AgentsAgentIdPatchParams"; export * from "./userRead"; export * from "./userUpdate"; diff --git a/frontend/src/api/generated/model/installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams.ts b/frontend/src/api/generated/model/installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams.ts new file mode 100644 index 00000000..6672a939 --- /dev/null +++ b/frontend/src/api/generated/model/installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export type InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams = + { + gateway_id: string; + }; diff --git a/frontend/src/api/generated/model/listMarketplaceSkillsApiV1SkillsMarketplaceGetParams.ts b/frontend/src/api/generated/model/listMarketplaceSkillsApiV1SkillsMarketplaceGetParams.ts new file mode 100644 index 00000000..8c3ba180 --- /dev/null +++ b/frontend/src/api/generated/model/listMarketplaceSkillsApiV1SkillsMarketplaceGetParams.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export type ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams = { + gateway_id: string; +}; diff --git a/frontend/src/api/generated/model/marketplaceSkillActionResponse.ts b/frontend/src/api/generated/model/marketplaceSkillActionResponse.ts new file mode 100644 index 00000000..218a0789 --- /dev/null +++ b/frontend/src/api/generated/model/marketplaceSkillActionResponse.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +/** + * Install/uninstall action response payload. + */ +export interface MarketplaceSkillActionResponse { + gateway_id: string; + installed: boolean; + ok?: boolean; + skill_id: string; +} diff --git a/frontend/src/api/generated/model/marketplaceSkillCardRead.ts b/frontend/src/api/generated/model/marketplaceSkillCardRead.ts new file mode 100644 index 00000000..5d0a7c8a --- /dev/null +++ b/frontend/src/api/generated/model/marketplaceSkillCardRead.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +/** + * Marketplace card payload with gateway-specific install state. + */ +export interface MarketplaceSkillCardRead { + created_at: string; + description?: string | null; + id: string; + installed: boolean; + installed_at?: string | null; + name: string; + organization_id: string; + source_url: string; + updated_at: string; +} diff --git a/frontend/src/api/generated/model/marketplaceSkillCreate.ts b/frontend/src/api/generated/model/marketplaceSkillCreate.ts new file mode 100644 index 00000000..8b9f6763 --- /dev/null +++ b/frontend/src/api/generated/model/marketplaceSkillCreate.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 skill URL in the organization marketplace. + */ +export interface MarketplaceSkillCreate { + description?: string | null; + name?: string | null; + /** @minLength 1 */ + source_url: string; +} diff --git a/frontend/src/api/generated/model/marketplaceSkillRead.ts b/frontend/src/api/generated/model/marketplaceSkillRead.ts new file mode 100644 index 00000000..b834ee60 --- /dev/null +++ b/frontend/src/api/generated/model/marketplaceSkillRead.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +/** + * Serialized marketplace skill catalog record. + */ +export interface MarketplaceSkillRead { + created_at: string; + description?: string | null; + id: string; + name: string; + organization_id: string; + source_url: string; + updated_at: string; +} diff --git a/frontend/src/api/generated/model/uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams.ts b/frontend/src/api/generated/model/uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams.ts new file mode 100644 index 00000000..d5a5a8d9 --- /dev/null +++ b/frontend/src/api/generated/model/uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export type UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams = + { + gateway_id: string; + }; diff --git a/frontend/src/api/generated/skills-marketplace/skills-marketplace.ts b/frontend/src/api/generated/skills-marketplace/skills-marketplace.ts new file mode 100644 index 00000000..9dcea697 --- /dev/null +++ b/frontend/src/api/generated/skills-marketplace/skills-marketplace.ts @@ -0,0 +1,939 @@ +/** + * 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, + 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 a skill source URL in the organization's marketplace 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, + ); + }; diff --git a/frontend/src/app/skills/page.tsx b/frontend/src/app/skills/page.tsx new file mode 100644 index 00000000..1c7cc24f --- /dev/null +++ b/frontend/src/app/skills/page.tsx @@ -0,0 +1,412 @@ +"use client"; + +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" + /> +
+
+ +